🚀 Auto-deploy: BrainWind atualizado em 29/07/2026 12:00:49

This commit is contained in:
2026-07-29 12:00:49 +00:00
parent fde885ca60
commit a30125c7ce
21 changed files with 932 additions and 210 deletions
+5 -5
View File
@@ -132,9 +132,9 @@ export function AirflowSystem({ windAngle, width, length, height, permeabilityCa
if (inY && inX && inZ) {
// Perto das paredes
if (Math.abs(pz - (-halfL)) < margin) localCpe = wallCpe.A;
else if (Math.abs(pz - (halfL)) < margin) localCpe = wallCpe.B;
else if (Math.abs(pz - (halfL)) < margin) localCpe = (windAngle === 90 ? 0.5 : wallCpe.B);
else if (Math.abs(px - (-halfW)) < margin) localCpe = wallCpe.C;
else if (Math.abs(px - (halfW)) < margin) localCpe = wallCpe.D;
else if (Math.abs(px - (halfW)) < margin) localCpe = (windAngle === 0 ? 0.5 : wallCpe.D);
else localCpe = cpi;
// Colisão dura (Impede entrar na parede sólida)
@@ -195,12 +195,12 @@ export function AirflowSystem({ windAngle, width, length, height, permeabilityCa
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));
colorObj.set(isDark ? '#f87171' : '#ef4444'); // Vermelho (Turbulento/Pressão)
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj.set(isDark ? '#7dd3fc' : '#38bdf8').lerp(new THREE.Color(isDark ? '#1e40af' : '#1d4ed8'), intensity);
colorObj.set(isDark ? '#c084fc' : '#a855f7').lerp(new THREE.Color(isDark ? '#7e22ce' : '#6b21a8'), intensity); // Roxo (Acelerado/Sucção)
} else {
colorObj.set(isDark ? '#f1f5f9' : '#64748b');
colorObj.set(isDark ? '#60a5fa' : '#3b82f6'); // Azul (Corrente livre)
}
meshRef.current!.setColorAt(i, colorObj);
});
+56 -32
View File
@@ -6,6 +6,7 @@ import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
import { WindArrow } from './WindArrow';
import { useCanvasTheme } from '@/lib/theme';
import { BridgeAirflowSystem } from './BridgeAirflowSystem';
export interface Bridge3DInput {
/** Maior vão Lₚ (m) */
@@ -26,6 +27,8 @@ export interface Bridge3DInput {
fzPerLength: number;
/** Ângulo de ataque do vento (graus) */
alpha: number;
/** Modo de visualização */
viewMode?: 'solid' | 'airflow';
}
function BridgeModel({
@@ -34,10 +37,15 @@ function BridgeModel({
deckHeight,
heg,
cx,
cz,
fxPerLength,
fzPerLength,
alpha,
viewMode = 'solid',
}: Bridge3DInput) {
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const isAirflow = viewMode === 'airflow';
const halfL = lp / 2;
const halfW = width / 2;
@@ -46,33 +54,34 @@ function BridgeModel({
// Cor do tabuleiro baseada em Cx
const deckColor = useMemo(() => {
if (isAirflow) return new THREE.Color(isDark ? '#334155' : '#e2e8f0');
const intensity = Math.min(1, Math.abs(cx) / 3);
const hue = 200 - intensity * 60;
const l = isDark ? (60 - intensity * 8) : (50 - intensity * 8);
return new THREE.Color(`hsl(${hue}, ${55 + intensity * 30}%, ${l}%)`);
}, [cx, isDark]);
}, [cx, isDark, isAirflow]);
// Pilar heights: posicionar 3 pilares ao longo do vão
const pillarHeights = useMemo(() => [deckY - 0.5, deckY - 0.5, deckY - 0.5], [deckY]);
const barrierColor = isDark ? '#cbd5e1' : '#94a3b8';
const pillarColor = isDark ? '#94a3b8' : '#64748b';
const barrierColor = isDark ? (isAirflow ? '#475569' : '#cbd5e1') : (isAirflow ? '#cbd5e1' : '#94a3b8');
const pillarColor = isDark ? (isAirflow ? '#334155' : '#94a3b8') : (isAirflow ? '#e2e8f0' : '#64748b');
return (
<group>
{/* Tabuleiro (deck) */}
<mesh position={[0, deckY, 0]} castShadow receiveShadow>
<boxGeometry args={[lp, deckThickness, width]} />
<meshStandardMaterial color={deckColor} roughness={0.5} />
<meshStandardMaterial color={deckColor} opacity={isAirflow ? 0.35 : 0.9} transparent roughness={0.5} />
</mesh>
{/* Guarda-rodas/barreira lateral */}
<mesh position={[0, deckY + deckThickness / 2 + 0.3, halfW - 0.15]} castShadow>
<boxGeometry args={[lp, 0.5, 0.1]} />
<meshStandardMaterial color={barrierColor} roughness={0.7} />
<meshStandardMaterial color={barrierColor} opacity={isAirflow ? 0.35 : 0.9} transparent roughness={0.7} />
</mesh>
<mesh position={[0, deckY + deckThickness / 2 + 0.3, -halfW + 0.15]} castShadow>
<boxGeometry args={[lp, 0.5, 0.1]} />
<meshStandardMaterial color={barrierColor} roughness={0.7} />
<meshStandardMaterial color={barrierColor} opacity={isAirflow ? 0.35 : 0.9} transparent roughness={0.7} />
</mesh>
{/* Pilares (3 ao longo do comprimento) */}
@@ -87,53 +96,68 @@ function BridgeModel({
})}
{/* Solo / água */}
<mesh position={[0, -0.5, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[lp * 1.6, width * 3]} />
<meshStandardMaterial color="#60a5fa" opacity={0.4} transparent roughness={0.3} />
</mesh>
{!isAirflow && (
<mesh position={[0, -0.5, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[lp * 1.6, width * 3]} />
<meshStandardMaterial color="#60a5fa" opacity={0.4} transparent roughness={0.3} />
</mesh>
)}
{/* Seta de Vento (bate no meio do tabuleiro) */}
<WindArrow
target={[0, deckY + deckThickness / 2, 0]}
direction={[0, Math.sin((alpha * Math.PI) / 180), Math.cos((alpha * Math.PI) / 180)]}
scale={4}
/>
<Text
position={[0, deckY + deckThickness + 1.5, 0]}
fontSize={1.2}
color={isDark ? "#93c5fd" : "#1e40af"}
anchorX="center"
anchorY="bottom"
>
Lp={lp}m | B={width}m | Cx={cx.toFixed(2)}
</Text>
{/* Seta de Vento */}
{isAirflow ? (
<group>
<WindArrow
target={[-halfL * 0.8, deckY, -halfW - 2]}
direction={[0, 0, 1]}
scale={4}
/>
<Text
position={[-halfL * 0.8, deckY + 1.5, -halfW - 4]}
rotation={[0, Math.PI / 4, 0]}
fontSize={1.2}
color={isDark ? '#60a5fa' : '#3b82f6'}
anchorX="center"
anchorY="bottom"
>
Vento
</Text>
<BridgeAirflowSystem width={width} lp={lp} deckHeight={deckHeight} cx={cx} />
</group>
) : (
<WindArrow
target={[0, deckY + deckThickness / 2, 0]}
direction={[0, Math.sin((alpha * Math.PI) / 180), Math.cos((alpha * Math.PI) / 180)]}
scale={4}
/>
)}
</group>
);
}
export default function Bridge3DViewer(input: Bridge3DInput) {
export default function Bridge3DViewer(props: Bridge3DInput) {
const { lp, width, deckHeight, heg, cx, cz, fxPerLength, fzPerLength, alpha, viewMode = 'solid' } = props;
const cameraDistance = Math.max(lp * 1.2, width * 2, deckHeight * 2, 15);
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const { lp, deckHeight, width } = input;
const dist = Math.max(lp * 0.6, deckHeight * 2);
const fallback = (
<FallbackDiagram
type="bridge"
props={input}
props={{ lp, width, deckHeight, heg, cx, cz, fxPerLength, fzPerLength }}
/>
);
return (
<SceneCanvas moduleId="ponte"
<SceneCanvas moduleId="pontes"
shadows
frameloop={viewMode === 'airflow' ? 'always' : 'demand'}
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [dist * 0.8, deckHeight + width, dist], fov: 45 }}
camera={{ position: [-cameraDistance * 0.8, cameraDistance * 0.6, -cameraDistance], fov: 45 }}
fallback={fallback}
>
<ambientLight intensity={0.6} />
<directionalLight position={[lp, deckHeight * 3, width * 3]} intensity={1.2} castShadow shadow-mapSize-width={1024} shadow-mapSize-height={1024} />
<BridgeModel {...input} />
<BridgeModel {...props} viewMode={viewMode} />
<Grid
infiniteGrid
fadeDistance={lp * 0.5}
@@ -0,0 +1,132 @@
import React, { useRef, useMemo, useEffect } from 'react';
import * as THREE from 'three';
import { useFrame } from '@react-three/fiber';
import { useCanvasTheme } from '@/lib/theme';
interface Particle {
position: THREE.Vector3;
baseSpeed: number;
wobbleSpeed: number;
wobbleOffset: number;
currentCpe: number;
}
interface BridgeAirflowSystemProps {
width: number;
lp: number;
deckHeight: number;
cx: number;
}
export const BridgeAirflowSystem: React.FC<BridgeAirflowSystemProps> = ({
width,
lp,
deckHeight,
cx,
}) => {
const meshRef = useRef<THREE.InstancedMesh>(null);
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const COUNT = 100;
const dummy = useMemo(() => new THREE.Object3D(), []);
const colorObj = useMemo(() => new THREE.Color(), []);
const halfWidth = width / 2;
const halfLp = lp / 2;
const particles = useMemo(() => {
const arr: Particle[] = [];
for (let i = 0; i < COUNT; i++) {
arr.push({
position: new THREE.Vector3(
(Math.random() - 0.5) * lp,
Math.random() * (deckHeight + 10),
-halfWidth - 10 - Math.random() * 20
),
baseSpeed: 0.075 + Math.random() * 0.05,
wobbleSpeed: 0.5 + Math.random() * 1.5,
wobbleOffset: Math.random() * Math.PI * 2,
currentCpe: 0,
});
}
return arr;
}, [width, lp, deckHeight]);
useEffect(() => {
if (meshRef.current) {
for (let i = 0; i < COUNT; i++) {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6');
meshRef.current.setColorAt(i, colorObj);
}
meshRef.current.instanceColor!.needsUpdate = true;
}
}, [isDark, colorObj]);
useFrame((state) => {
if (!meshRef.current) return;
const time = state.clock.elapsedTime;
particles.forEach((p, i) => {
const px = p.position.x;
const py = p.position.y;
const pz = p.position.z;
let currentSpeed = p.baseSpeed;
let localCpe = 0;
let deflectY = 0;
const inX = Math.abs(px) < halfLp + 1;
const inZ = Math.abs(pz) < halfWidth + 1;
if (inX && inZ) {
localCpe = cx;
// Tabuleiro deflete o vento
if (Math.abs(py - deckHeight) < 2) {
const dist = -pz;
const lift = Math.max(0, deckHeight + 1 - py) / (dist + 1);
deflectY += lift * currentSpeed * 0.6 * (py < deckHeight ? -1 : 1);
}
}
p.currentCpe += (localCpe - p.currentCpe) * 0.1;
p.position.z += currentSpeed;
p.position.y += deflectY;
p.position.y += Math.sin(time * p.wobbleSpeed * 10 + p.wobbleOffset) * 0.01;
if (p.position.z > halfWidth + 10) {
p.position.z = -halfWidth - 10 - Math.random() * 5;
p.position.y = Math.random() * (deckHeight + 10);
p.position.x = (Math.random() - 0.5) * lp;
p.currentCpe = 0;
}
dummy.position.copy(p.position);
dummy.rotation.set(0, 0, 0);
dummy.rotateX(Math.PI / 2);
dummy.scale.set(1, 1 + currentSpeed * 6, 1);
dummy.updateMatrix();
meshRef.current!.setMatrixAt(i, dummy.matrix);
if (p.currentCpe > 0) {
colorObj.set(isDark ? '#f87171' : '#ef4444');
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj.set(isDark ? '#c084fc' : '#a855f7').lerp(new THREE.Color(isDark ? '#7e22ce' : '#6b21a8'), intensity);
} else {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6');
}
meshRef.current!.setColorAt(i, colorObj);
});
meshRef.current.instanceMatrix.needsUpdate = true;
meshRef.current.instanceColor!.needsUpdate = true;
});
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, COUNT]}>
<cylinderGeometry args={[0.04, 0.04, 1.0, 4]} />
<meshBasicMaterial transparent opacity={0.6} depthWrite={false} />
</instancedMesh>
);
};
@@ -95,7 +95,7 @@ export function CylinderAirflowSystem({ diameter, height }: CylinderAirflowSyste
} else if (Math.abs(px) <= R * 0.6 && r < R * 1.5) {
p.currentCpe = -1.2; // Laterais (forte sucção)
} else if (px > R * 0.3 && Math.abs(pz) < R * 1.2) {
p.currentCpe = -0.5; // Esteira (sucção moderada)
p.currentCpe = 0.5; // Esteira (agora positivo para ficar vermelho)
} else {
p.currentCpe = 0;
}
@@ -123,16 +123,12 @@ export function CylinderAirflowSystem({ diameter, height }: CylinderAirflowSyste
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));
colorObj.set(isDark ? '#f87171' : '#ef4444'); // Vermelho (Turbulento/Pressão)
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj
.set(isDark ? '#7dd3fc' : '#38bdf8')
.lerp(new THREE.Color(isDark ? '#1e40af' : '#1d4ed8'), intensity);
colorObj.set(isDark ? '#c084fc' : '#a855f7').lerp(new THREE.Color(isDark ? '#7e22ce' : '#6b21a8'), intensity); // Roxo (Acelerado/Sucção)
} else {
colorObj.set(isDark ? '#f1f5f9' : '#64748b');
colorObj.set(isDark ? '#60a5fa' : '#3b82f6'); // Azul (Corrente livre)
}
meshRef.current!.setColorAt(i, colorObj);
});
+2 -2
View File
@@ -133,7 +133,7 @@ function DomeModel({ diameter, rise, wallHeight, cpi, cpeBarlavento, cpeTopo, cp
})}
{/* Seta indicativa de direção do vento */}
<group position={[radius + 2.5, wallHeight / 2, 0]} rotation={[0, 0, Math.PI / 2]}>
<group position={[-radius - 2.5, wallHeight / 2, 0]} rotation={[0, 0, -Math.PI / 2]}>
<mesh castShadow>
<coneGeometry args={[0.3, 0.8, 16]} />
<meshStandardMaterial color="#3b82f6" roughness={0.3} />
@@ -146,7 +146,7 @@ function DomeModel({ diameter, rise, wallHeight, cpi, cpeBarlavento, cpeTopo, cp
{/* Texto Vento */}
<Text
position={[radius + 2.5, wallHeight / 2 + 1.2, 0]}
position={[-radius - 2.5, wallHeight / 2 + 1.2, 0]}
rotation={[0, 0, 0]}
fontSize={1.2}
color="#3b82f6"
@@ -123,8 +123,10 @@ export function DomeAirflowSystem({ diameter, rise, wallHeight }: DomeAirflowSys
p.currentCpe = 0.6; // Barlavento
} else if (Math.abs(px) <= R * 0.4 && py > wallHeight) {
p.currentCpe = -1.0; // Topo da cúpula (forte sucção)
} else if (px > R * 0.2) {
p.currentCpe = 0.5; // Sotavento / Esteira (agora positivo para ficar vermelho)
} else {
p.currentCpe = -0.5; // Lateral / Sotavento
p.currentCpe = -0.5; // Laterais / transição
}
} else {
p.currentCpe = 0;
@@ -151,16 +153,12 @@ export function DomeAirflowSystem({ diameter, rise, wallHeight }: DomeAirflowSys
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));
colorObj.set(isDark ? '#f87171' : '#ef4444'); // Vermelho (Turbulento/Pressão)
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj
.set(isDark ? '#7dd3fc' : '#38bdf8')
.lerp(new THREE.Color(isDark ? '#1e40af' : '#1d4ed8'), intensity);
colorObj.set(isDark ? '#c084fc' : '#a855f7').lerp(new THREE.Color(isDark ? '#7e22ce' : '#6b21a8'), intensity); // Roxo (Acelerado/Sucção)
} else {
colorObj.set(isDark ? '#f1f5f9' : '#64748b');
colorObj.set(isDark ? '#60a5fa' : '#3b82f6'); // Azul (Corrente livre)
}
meshRef.current!.setColorAt(i, colorObj);
});
+64 -36
View File
@@ -4,12 +4,12 @@ import { ViewerOrbitControls as OrbitControls } from './ViewerOrbitControls';
import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import FallbackDiagram from '../FallbackDiagram';
import { WindArrow } from './WindArrow';
import { useCanvasTheme } from '@/lib/theme';
import { IsolatedRoofAirflowSystem } from './IsolatedRoofAirflowSystem';
export interface IsolatedRoof3DInput {
/** Tipo de cobertura: 'shed' (uma água) ou 'gable' (duas águas) */
type: 'shed' | 'gable';
/** Tipo de cobertura: 'shed' (uma água), 'gable' (duas águas) ou 'butterfly' (borboleta) */
type: 'shed' | 'gable' | 'butterfly';
/** Inclinação θ (graus) */
theta: number;
/** Altura livre dos suportes (m) */
@@ -26,6 +26,7 @@ export interface IsolatedRoof3DInput {
cpeTop: number;
/** Força resultante na cobertura (kN) */
forceKN: number;
viewMode?: 'solid' | 'airflow';
}
function pressureColor(cpe: number, isDark: boolean): THREE.Color {
@@ -50,9 +51,11 @@ function IsolatedRoofModel({
cpeWindward,
cpeLeeward,
forceKN,
viewMode = 'solid',
}: IsolatedRoof3DInput) {
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const isAirflow = viewMode === 'airflow';
const labelColor = isDark ? '#cbd5e1' : '#475569';
const textColor = isDark ? '#93c5fd' : '#1a202c';
const lineMaterialColor = isDark ? '#94a3b8' : '#64748b';
@@ -81,7 +84,7 @@ function IsolatedRoofModel({
{ pos: [-halfWidth, (height + h_diff) / 2, halfDepth], h: height + h_diff },
{ pos: [halfWidth, (height + h_diff) / 2, halfDepth], h: height + h_diff },
);
} else {
} else if (type === 'gable') {
list.push(
{ pos: [-halfWidth, height / 2, -halfDepth], h: height },
{ pos: [halfWidth, height / 2, -halfDepth], h: height },
@@ -90,6 +93,15 @@ function IsolatedRoofModel({
{ pos: [-halfWidth, (height + h_half) / 2, 0], h: height + h_half },
{ pos: [halfWidth, (height + h_half) / 2, 0], h: height + h_half },
);
} else if (type === 'butterfly') {
list.push(
{ pos: [-halfWidth, (height + h_half) / 2, -halfDepth], h: height + h_half },
{ pos: [halfWidth, (height + h_half) / 2, -halfDepth], h: height + h_half },
{ pos: [-halfWidth, (height + h_half) / 2, halfDepth], h: height + h_half },
{ pos: [halfWidth, (height + h_half) / 2, halfDepth], h: height + h_half },
{ pos: [-halfWidth, height / 2, 0], h: height },
{ pos: [halfWidth, height / 2, 0], h: height },
);
}
return list;
}, [type, depth, width, height, h_diff, h_half, halfDepth, halfWidth]);
@@ -106,25 +118,9 @@ function IsolatedRoofModel({
{type === 'shed' ? (
// Uma água (Shed): dividida em metade barlavento e metade sotavento
<group>
{/* Metade Barlavento (Z < 0) */}
<mesh
position={[0, height + h_diff / 4, -depth / 4]}
rotation={[-thetaRad, 0, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[width, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<meshStandardMaterial color={windwardColor} opacity={0.9} transparent roughness={0.4} />
</mesh>
{/* Metade Sotavento (Z > 0) */}
<mesh
position={[0, height + (3 * h_diff) / 4, depth / 4]}
rotation={[-thetaRad, 0, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[width, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<meshStandardMaterial color={leewardColor} opacity={0.9} transparent roughness={0.4} />
<mesh position={[0, height + h_diff / 2, 0]} rotation={[-thetaRad, 0, 0]} castShadow receiveShadow>
<boxGeometry args={[width, 0.08, depth / Math.cos(thetaRad)]} />
<meshStandardMaterial color={windwardColor} opacity={isAirflow ? 0.35 : 0.9} transparent roughness={0.4} />
</mesh>
</group>
) : (
@@ -133,22 +129,22 @@ function IsolatedRoofModel({
{/* Água Esquerda / Barlavento (Z < 0) */}
<mesh
position={[0, height + h_half / 2, -depth / 4]}
rotation={[-thetaRad, 0, 0]}
rotation={[type === 'butterfly' ? thetaRad : -thetaRad, 0, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[width, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<meshStandardMaterial color={windwardColor} opacity={0.9} transparent roughness={0.4} />
<meshStandardMaterial color={windwardColor} opacity={isAirflow ? 0.35 : 0.9} transparent roughness={0.4} />
</mesh>
{/* Água Direita / Sotavento (Z > 0) */}
<mesh
position={[0, height + h_half / 2, depth / 4]}
rotation={[thetaRad, 0, 0]}
castShadow
<mesh
position={[0, height + h_half / 2, depth / 4]}
rotation={[type === 'butterfly' ? -thetaRad : thetaRad, 0, 0]}
castShadow
receiveShadow
>
<boxGeometry args={[width, 0.08, depth / (2 * Math.cos(thetaRad))]} />
<meshStandardMaterial color={leewardColor} opacity={0.9} transparent roughness={0.4} />
<meshStandardMaterial color={leewardColor} opacity={isAirflow ? 0.35 : 0.9} transparent roughness={0.4} />
</mesh>
</group>
)}
@@ -161,11 +157,27 @@ function IsolatedRoofModel({
</mesh>
))}
{/* Vetor de vento (Seta verde/azul batendo no elemento) */}
<WindArrow
target={new THREE.Vector3(0, centerY, 0)}
direction={new THREE.Vector3(0, 0, 1)}
/>
{/* Vetor de vento (Seta mais visível com texto) */}
<group position={[0, centerY, -halfDepth - 3.5]} rotation={[Math.PI / 2, 0, 0]}>
<mesh castShadow>
<coneGeometry args={[0.4, 1.2, 16]} />
<meshStandardMaterial color="#3b82f6" roughness={0.3} />
</mesh>
<mesh position={[0, -1.0, 0]} castShadow>
<cylinderGeometry args={[0.15, 0.15, 2.0, 16]} />
<meshStandardMaterial color="#3b82f6" roughness={0.3} />
</mesh>
</group>
<Text
position={[0, centerY + 1.2, -halfDepth - 3.5]}
rotation={[0, -3 * Math.PI / 4, 0]}
fontSize={1.2}
color="#3b82f6"
anchorX="center"
anchorY="bottom"
>
Vento
</Text>
{/* === LINHAS DE COTA (CAD-Style) === */}
{/* Cota de Altura (h) */}
@@ -248,6 +260,8 @@ function IsolatedRoofModel({
{/* Rótulo Superior */}
<Text
position={[0, centerY + 3, 0]}
rotation={[0, -3 * Math.PI / 4, 0]}
fontSize={Math.max(0.4, width / 15)}
color={textColor}
anchorX="center"
anchorY="bottom"
@@ -268,6 +282,7 @@ export default function IsolatedRoof3DViewer({
cpeLeeward,
cpeTop,
forceKN,
viewMode = 'solid',
}: IsolatedRoof3DInput) {
const cameraDistance = Math.max(Math.max(width, depth) * 1.3, height * 1.5, 8);
@@ -281,8 +296,9 @@ export default function IsolatedRoof3DViewer({
return (
<SceneCanvas moduleId="cobertura_isolada"
shadows
frameloop={viewMode === 'airflow' ? 'always' : 'demand'}
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [cameraDistance, cameraDistance * 0.8, cameraDistance], fov: 40 }}
camera={{ position: [-cameraDistance * 0.8, cameraDistance * 0.8, -cameraDistance], fov: 40 }}
fallback={fallback}
>
<ambientLight intensity={0.7} />
@@ -303,7 +319,19 @@ export default function IsolatedRoof3DViewer({
cpeLeeward={cpeLeeward}
cpeTop={cpeTop}
forceKN={forceKN}
viewMode={viewMode}
/>
{viewMode === 'airflow' && (
<IsolatedRoofAirflowSystem
type={type}
theta={theta}
height={height}
width={width}
depth={depth}
cpeWindward={cpeWindward}
cpeLeeward={cpeLeeward}
/>
)}
<Grid infiniteGrid fadeDistance={cameraDistance * 5} sectionColor="#94a3b8" cellColor="#cbd5e1" position={[0, -0.01, 0]} />
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2 - 0.05} />
<Environment preset="city" />
@@ -0,0 +1,170 @@
import React, { useRef, useMemo, useEffect } from 'react';
import * as THREE from 'three';
import { useFrame } from '@react-three/fiber';
import { useCanvasTheme } from '@/lib/theme';
interface Particle {
position: THREE.Vector3;
baseSpeed: number;
wobbleSpeed: number;
wobbleOffset: number;
currentCpe: number;
}
interface IsolatedRoofAirflowSystemProps {
type: 'shed' | 'gable' | 'butterfly';
theta: number;
height: number;
width: number;
depth: number;
cpeWindward: number;
cpeLeeward: number;
}
export const IsolatedRoofAirflowSystem: React.FC<IsolatedRoofAirflowSystemProps> = ({
type,
theta,
height,
width,
depth,
cpeWindward,
cpeLeeward,
}) => {
const meshRef = useRef<THREE.InstancedMesh>(null);
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const COUNT = 100;
const dummy = useMemo(() => new THREE.Object3D(), []);
const colorObj = useMemo(() => new THREE.Color(), []);
const thetaRad = (theta * Math.PI) / 180;
const halfDepth = depth / 2;
const halfWidth = width / 2;
const h_diff = depth * Math.tan(thetaRad);
const h_half = halfDepth * Math.tan(thetaRad);
const particles = useMemo(() => {
const arr: Particle[] = [];
for (let i = 0; i < COUNT; i++) {
arr.push({
position: new THREE.Vector3(
(Math.random() - 0.5) * width * 3.0,
Math.random() * (height + h_diff + 3),
-halfDepth - 5 - Math.random() * 20
),
baseSpeed: 0.075 + Math.random() * 0.05,
wobbleSpeed: 0.5 + Math.random() * 1.5,
wobbleOffset: Math.random() * Math.PI * 2,
currentCpe: 0,
});
}
return arr;
}, [width, height, depth, h_diff]);
useEffect(() => {
if (meshRef.current) {
for (let i = 0; i < COUNT; i++) {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6');
meshRef.current.setColorAt(i, colorObj);
}
meshRef.current.instanceColor!.needsUpdate = true;
}
}, [isDark, colorObj]);
useFrame((state) => {
if (!meshRef.current) return;
const time = state.clock.elapsedTime;
particles.forEach((p, i) => {
const px = p.position.x;
const py = p.position.y;
const pz = p.position.z;
let currentSpeed = p.baseSpeed;
let localCpe = 0;
let deflectY = 0;
const inX = Math.abs(px) < halfWidth + 1;
const inZ = Math.abs(pz) < halfDepth + 2;
if (inX && inZ) {
if (type === 'butterfly') {
if (pz < 0) {
localCpe = cpeWindward;
// Vento acompanha a descida em direção ao vale
if (py > height + 0.2) {
deflectY -= currentSpeed * 0.3;
}
} else {
localCpe = cpeLeeward;
// Vento bate na segunda água que sobe e é defletido para cima
if (py < height + h_half + 2) {
deflectY += currentSpeed * 0.6;
}
}
} else {
if (pz < 0) {
localCpe = cpeWindward;
const maxH = type === 'shed' ? height + h_diff : height + h_half;
if (py < maxH + 1) {
const dist = -pz;
const lift = Math.max(0, maxH - py) / (dist + 1);
deflectY += lift * currentSpeed * 0.4;
}
} else {
localCpe = cpeLeeward;
if (cpeLeeward < 0) {
deflectY -= currentSpeed * 0.15;
p.position.y += Math.sin(time * 5 + p.wobbleOffset) * 0.05;
}
}
}
if (pz > halfDepth && py < height + h_diff) {
localCpe = 0.5; // Esteira
deflectY -= currentSpeed * 0.2;
}
}
p.currentCpe += (localCpe - p.currentCpe) * 0.1;
p.position.z += currentSpeed;
p.position.y += deflectY;
p.position.y += Math.sin(time * p.wobbleSpeed * 10 + p.wobbleOffset) * 0.01;
if (p.position.z > halfDepth + 10) {
p.position.z = -halfDepth - 10 - Math.random() * 5;
p.position.y = Math.random() * (height + h_diff + 3);
p.position.x = (Math.random() - 0.5) * width * 3.0;
p.currentCpe = 0;
}
dummy.position.copy(p.position);
dummy.rotation.set(0, 0, 0);
dummy.rotateX(Math.PI / 2);
dummy.scale.set(1, 1 + currentSpeed * 6, 1);
dummy.updateMatrix();
meshRef.current!.setMatrixAt(i, dummy.matrix);
if (p.currentCpe > 0) {
colorObj.set(isDark ? '#f87171' : '#ef4444');
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj.set(isDark ? '#c084fc' : '#a855f7').lerp(new THREE.Color(isDark ? '#7e22ce' : '#6b21a8'), intensity);
} else {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6');
}
meshRef.current!.setColorAt(i, colorObj);
});
meshRef.current.instanceMatrix.needsUpdate = true;
meshRef.current.instanceColor!.needsUpdate = true;
});
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, COUNT]}>
<cylinderGeometry args={[0.04, 0.04, 1.0, 4]} />
<meshBasicMaterial transparent opacity={0.6} depthWrite={false} />
</instancedMesh>
);
};
+50 -77
View File
@@ -2,9 +2,11 @@ import React, { useRef } from 'react';
import * as THREE from 'three';
import { useFrame } from '@react-three/fiber';
import { Text, Grid, Environment } from '@react-three/drei';
import { WindArrow as GlobalWindArrow } from './WindArrow';
import { ViewerOrbitControls as OrbitControls } from './ViewerOrbitControls';
import SceneCanvas from '../SceneCanvas';
import { useCanvasTheme } from '@/lib/theme';
import { ShelterAirflowSystem } from './ShelterAirflowSystem';
import type { ShelterCondition, OpeningPosition, ShelterWindAngle } from '@/lib/nbr-tables/table-shelters';
export interface Shelter3DProps {
@@ -19,43 +21,9 @@ export interface Shelter3DProps {
openingPos: OpeningPosition;
backRange?: [number, number];
windAngle?: ShelterWindAngle;
viewMode: '3d' | 'elevation';
viewMode: '3d' | 'elevation' | 'airflow';
}
const WindArrow: React.FC<{
start: [number, number, number];
end: [number, number, number];
color?: string;
speed?: number;
}> = ({ start, end, color = '#38bdf8', speed = 1.5 }) => {
const arrowRef = useRef<THREE.Group>(null);
const quaternion = React.useMemo(() => {
const dir = new THREE.Vector3(...end).sub(new THREE.Vector3(...start)).normalize();
return new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
}, [start, end]);
useFrame(({ clock, invalidate }) => {
if (arrowRef.current) {
const t = (clock.getElapsedTime() * speed) % 1;
const x = start[0] + (end[0] - start[0]) * t;
const y = start[1] + (end[1] - start[1]) * t;
const z = start[2] + (end[2] - start[2]) * t;
arrowRef.current.position.set(x, y, z);
invalidate();
}
});
return (
<group ref={arrowRef} quaternion={quaternion}>
<mesh>
<coneGeometry args={[0.25, 0.6, 8]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={0.6} />
</mesh>
</group>
);
};
function ShelterModel({
condition: _condition,
depth,
@@ -71,10 +39,13 @@ function ShelterModel({
viewMode,
}: Shelter3DProps) {
const isElevation = viewMode === 'elevation';
const theme = useCanvasTheme();
const isDark = theme === 'dark';
// Geometria básica
const halfW = width / 2;
const halfD = depth / 2;
const isAirflow = viewMode === 'airflow';
const thetaRad = (theta * Math.PI) / 180;
const slopeRise = depth * Math.tan(thetaRad);
@@ -161,50 +132,51 @@ function ShelterModel({
{/* Parede Lateral Direita (X = +halfW) */}
{renderWallWithGap(depth, height, rightClosure, 'distributed', Math.PI / 2, [halfW, 0, 0])}
{/* 4. Linhas de Corrente e Vetores de Vento (Ilustrativo e Didático) */}
<group position={[0, 0, 0]} rotation={[0, -((windAngle || 0) * Math.PI) / 180, 0]}>
{/* Vento de entrada (Barlavento) entrando em direção ao abrigo */}
<WindArrow start={[0, height * 0.5, -halfD - 4]} end={[0, height * 0.5, -halfD + 1]} color="#38bdf8" speed={1.2} />
<WindArrow start={[-halfW * 0.5, height * 0.7, -halfD - 4]} end={[-halfW * 0.5, height * 0.7, -halfD + 1]} color="#38bdf8" speed={1.4} />
<WindArrow start={[halfW * 0.5, height * 0.4, -halfD - 4]} end={[halfW * 0.5, height * 0.4, -halfD + 1]} color="#38bdf8" speed={1.3} />
{/* Linhas de corrente sobre o telhado */}
<WindArrow start={[0, height + 1, -halfD - 3]} end={[0, height + slopeRise + 1.5, halfD + 2]} color="#60a5fa" speed={1.6} />
{/* Fresta na parede de fundo: se houver abertura no topo, mostrar vento escapando pela fresta superior */}
{backClosure > 0 && backClosure < 100 && (openingPos === 'top' || (backRange && backRange[1] < 98)) && (
<>
<WindArrow start={[0, height * 0.85, 0]} end={[0, height * 0.95, halfD + 2.5]} color="#34d399" speed={1.8} />
<Text position={[0, height + 0.35, halfD]} rotation={[0, Math.PI, 0]} fontSize={0.4} color="#34d399">
Alívio (Fresta Topo)
</Text>
</>
)}
{/* Fresta na base */}
{backClosure > 0 && backClosure < 100 && (openingPos === 'bottom' || (backRange && backRange[0] > 2)) && (
<>
<WindArrow start={[0, 0.4, 0]} end={[0, 0.4, halfD + 2.5]} color="#facc15" speed={1.8} />
<Text position={[0, 1.2, halfD]} rotation={[0, Math.PI, 0]} fontSize={0.4} color="#facc15">
Vão Livre Inferior
</Text>
</>
)}
{/* Seta de Vento Global (Sempre visível para não pular de posição) */}
<group>
<GlobalWindArrow
target={[-halfW * 1.2, height * 1.5 - 1.5, -halfD - 5 + 2]}
direction={[0, 0, 1]}
scale={4}
/>
<Text
position={[-halfW * 1.2, height * 1.5, -halfD - 5]}
rotation={[0, Math.PI / 4, 0]}
fontSize={1.2}
color={isDark ? '#60a5fa' : '#3b82f6'}
anchorX="center"
anchorY="bottom"
>
Vento
</Text>
</group>
{/* Partículas de Ar */}
{isAirflow && (
<group>
<ShelterAirflowSystem
width={width}
depth={depth}
height={height}
theta={theta}
/>
</group>
)}
{/* Textos Informativos 3D - Virados para quem olha da frente (-Z) */}
<Text position={[0, height + slopeRise + 0.8, 0]} rotation={[0, Math.PI, 0]} fontSize={0.45} color="#e2e8f0">
{isElevation ? 'CORTE A-A (Elevação)' : 'Abrigo / Pórtico (NBR 6123)'}
</Text>
<Text position={[0, 0.3, -halfD - 1.2]} rotation={[0, Math.PI, 0]} fontSize={0.35} color="#38bdf8">
{`VENTO ${windAngle === 0 ? 'FRONTAL (0°)' : windAngle === 45 ? 'OBLÍQUO (45°)' : 'LATERAL (90°)'}`}
</Text>
<Text position={[0, height + slopeRise + 0.25, 0]} rotation={[0, Math.PI, 0]} fontSize={0.32} color="#f43f5e">
Arrancamento [+Fz ]
</Text>
<Text position={[0, height * 0.5, halfD + 1.2]} rotation={[0, Math.PI, 0]} fontSize={0.32} color="#fb923c">
Empuxo no Fundo [+Fx ]
</Text>
{!isAirflow && (
<>
<Text position={[0, height + slopeRise + 1.2, 0]} rotation={[0, Math.PI, 0]} fontSize={0.45} color="#e2e8f0">
{isElevation ? 'CORTE A-A (Elevação)' : 'Abrigo / Pórtico (NBR 6123)'}
</Text>
<Text position={[0, height + slopeRise + 0.5, 0]} rotation={[0, Math.PI, 0]} fontSize={0.32} color="#f43f5e">
Arrancamento [+Fz ]
</Text>
<Text position={[0, height * 0.5, halfD + 1.2]} rotation={[0, Math.PI, 0]} fontSize={0.32} color="#fb923c">
Empuxo no Fundo [+Fx ]
</Text>
</>
)}
</group>
);
}
@@ -212,13 +184,14 @@ function ShelterModel({
export function Shelter3D(props: Shelter3DProps) {
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const { width, depth, height } = props;
const { width, depth, height, viewMode } = props;
const dist = Math.max(width, depth) * 1.8 + 8;
return (
<SceneCanvas
moduleId="shelter"
shadows
frameloop={viewMode === 'airflow' ? 'always' : 'demand'}
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [dist * 0.75, height * 1.1, -dist * 0.85], fov: 45 }}
fallback={<div className="flex items-center justify-center h-full text-muted-foreground">3D indisponível</div>}
@@ -0,0 +1,134 @@
import React, { useRef, useMemo, useEffect } from 'react';
import * as THREE from 'three';
import { useFrame } from '@react-three/fiber';
import { useCanvasTheme } from '@/lib/theme';
interface Particle {
position: THREE.Vector3;
baseSpeed: number;
wobbleSpeed: number;
wobbleOffset: number;
currentCpe: number;
}
interface ShelterAirflowSystemProps {
width: number;
depth: number;
height: number;
theta: number;
}
export const ShelterAirflowSystem: React.FC<ShelterAirflowSystemProps> = ({
width,
depth,
height,
theta,
}) => {
const meshRef = useRef<THREE.InstancedMesh>(null);
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const COUNT = 100;
const dummy = useMemo(() => new THREE.Object3D(), []);
const colorObj = useMemo(() => new THREE.Color(), []);
const halfWidth = width / 2;
const halfDepth = depth / 2;
const thetaRad = (theta * Math.PI) / 180;
const h_diff = depth * Math.tan(thetaRad);
const particles = useMemo(() => {
const arr: Particle[] = [];
for (let i = 0; i < COUNT; i++) {
arr.push({
position: new THREE.Vector3(
(Math.random() - 0.5) * width * 3,
Math.random() * (height + h_diff + 5),
-halfDepth - 10 - Math.random() * 20
),
baseSpeed: 0.075 + Math.random() * 0.05,
wobbleSpeed: 0.5 + Math.random() * 1.5,
wobbleOffset: Math.random() * Math.PI * 2,
currentCpe: 0,
});
}
return arr;
}, [width, depth, height, h_diff]);
useEffect(() => {
if (meshRef.current) {
for (let i = 0; i < COUNT; i++) {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6');
meshRef.current.setColorAt(i, colorObj);
}
meshRef.current.instanceColor!.needsUpdate = true;
}
}, [isDark, colorObj]);
useFrame((state) => {
if (!meshRef.current) return;
const time = state.clock.elapsedTime;
particles.forEach((p, i) => {
const px = p.position.x;
const py = p.position.y;
const pz = p.position.z;
let currentSpeed = p.baseSpeed;
let localCpe = 0;
let deflectY = 0;
const inX = Math.abs(px) < halfWidth + 1;
const inZ = Math.abs(pz) < halfDepth + 1;
if (inX && inZ) {
localCpe = 0.5;
// Teto inclinado deflete o vento
if (py < height + h_diff + 1 && py > height - 1) {
const dist = -pz;
const lift = Math.max(0, height + h_diff - py) / (dist + 1);
deflectY += lift * currentSpeed * 0.4;
}
}
p.currentCpe += (localCpe - p.currentCpe) * 0.1;
p.position.z += currentSpeed;
p.position.y += deflectY;
p.position.y += Math.sin(time * p.wobbleSpeed * 10 + p.wobbleOffset) * 0.01;
if (p.position.z > halfDepth + 10) {
p.position.z = -halfDepth - 10 - Math.random() * 5;
p.position.y = Math.random() * (height + h_diff + 5);
p.position.x = (Math.random() - 0.5) * width * 3;
p.currentCpe = 0;
}
dummy.position.copy(p.position);
dummy.rotation.set(0, 0, 0);
dummy.rotateX(Math.PI / 2);
dummy.scale.set(1, 1 + currentSpeed * 6, 1);
dummy.updateMatrix();
meshRef.current!.setMatrixAt(i, dummy.matrix);
if (p.currentCpe > 0) {
colorObj.set(isDark ? '#f87171' : '#ef4444');
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj.set(isDark ? '#c084fc' : '#a855f7').lerp(new THREE.Color(isDark ? '#7e22ce' : '#6b21a8'), intensity);
} else {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6');
}
meshRef.current!.setColorAt(i, colorObj);
});
meshRef.current.instanceMatrix.needsUpdate = true;
meshRef.current.instanceColor!.needsUpdate = true;
});
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, COUNT]}>
<cylinderGeometry args={[0.04, 0.04, 1.0, 4]} />
<meshBasicMaterial transparent opacity={0.6} depthWrite={false} />
</instancedMesh>
);
};
@@ -152,16 +152,12 @@ export function SignAirflowSystem({ length, height, groundClearance, alpha, cf }
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));
colorObj.set(isDark ? '#f87171' : '#ef4444'); // Vermelho (Turbulento/Pressão)
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj
.set(isDark ? '#7dd3fc' : '#38bdf8')
.lerp(new THREE.Color(isDark ? '#1e40af' : '#1d4ed8'), intensity);
colorObj.set(isDark ? '#c084fc' : '#a855f7').lerp(new THREE.Color(isDark ? '#7e22ce' : '#6b21a8'), intensity); // Roxo (Acelerado/Sucção)
} else {
colorObj.set(isDark ? '#f1f5f9' : '#64748b');
colorObj.set(isDark ? '#60a5fa' : '#3b82f6'); // Azul (Corrente livre)
}
meshRef.current!.setColorAt(i, colorObj);
});
+34 -7
View File
@@ -5,6 +5,7 @@ import * as THREE from 'three';
import SceneCanvas from '../SceneCanvas';
import { WindArrow } from './WindArrow';
import FallbackDiagram from '../FallbackDiagram';
import { TowerAirflowSystem } from './TowerAirflowSystem';
export interface Tower3DInput {
/** Forma da seção */
@@ -23,6 +24,8 @@ export interface Tower3DInput {
alphaWind: 0 | 45 | 90;
/** Força total estimada na torre (kN) */
forceKN: number;
/** Modo de visualização */
viewMode?: 'solid' | 'airflow';
}
/**
@@ -136,9 +139,11 @@ function TowerModel({
phi,
alphaWind,
forceKN,
viewMode = 'solid',
}: Tower3DInput) {
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const isAirflow = viewMode === 'airflow';
const geometry = useMemo(
() => buildTowerGeometry(section, panels, baseWidth, height),
@@ -201,11 +206,32 @@ function TowerModel({
);
})}
{/* Seta de Vento única atingindo o meio da torre */}
<WindArrow
target={[0, height / 2, 0]}
direction={forceDir}
/>
{/* Seta de Vento */}
{isAirflow ? (
<group>
<WindArrow
target={[-baseWidth * 1.5, height / 2, -baseWidth * 1.5]}
direction={[0, 0, 1]}
scale={4}
/>
<Text
position={[-baseWidth * 1.5, height / 2 + 1.5, -baseWidth * 1.5 - 2]}
rotation={[0, Math.PI / 4, 0]}
fontSize={1.2}
color={isDark ? '#60a5fa' : '#3b82f6'}
anchorX="center"
anchorY="bottom"
>
Vento
</Text>
<TowerAirflowSystem baseWidth={baseWidth} height={height} phi={phi} />
</group>
) : (
<WindArrow
target={[0, height / 2, 0]}
direction={forceDir}
/>
)}
{/* Labels indicativos */}
<mesh position={[baseWidth / 2 + 0.5, 0.5, 0]}>
@@ -228,7 +254,7 @@ function TowerModel({
export default function Tower3DViewer(input: Tower3DInput) {
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const { baseWidth, height } = input;
const { baseWidth, height, viewMode = 'solid' } = input;
const dist = Math.max(baseWidth * 3, height * 1.2);
const fallback = (
@@ -241,13 +267,14 @@ export default function Tower3DViewer(input: Tower3DInput) {
return (
<SceneCanvas moduleId="torre"
shadows
frameloop={viewMode === 'airflow' ? 'always' : 'demand'}
gl={{ preserveDrawingBuffer: true, antialias: true }}
camera={{ position: [dist, height * 0.6, dist], fov: 45 }}
fallback={fallback}
>
<ambientLight intensity={0.6} />
<directionalLight position={[dist, height, dist]} intensity={1.2} castShadow shadow-mapSize-width={1024} shadow-mapSize-height={1024} />
<TowerModel {...input} />
<TowerModel {...input} viewMode={viewMode} />
<Grid
infiniteGrid
fadeDistance={height * 2}
@@ -0,0 +1,130 @@
import React, { useRef, useMemo, useEffect } from 'react';
import * as THREE from 'three';
import { useFrame } from '@react-three/fiber';
import { useCanvasTheme } from '@/lib/theme';
interface Particle {
position: THREE.Vector3;
baseSpeed: number;
wobbleSpeed: number;
wobbleOffset: number;
currentCpe: number;
}
interface TowerAirflowSystemProps {
baseWidth: number;
height: number;
phi: number;
}
export const TowerAirflowSystem: React.FC<TowerAirflowSystemProps> = ({
baseWidth,
height,
phi,
}) => {
const meshRef = useRef<THREE.InstancedMesh>(null);
const theme = useCanvasTheme();
const isDark = theme === 'dark';
const COUNT = 100;
const dummy = useMemo(() => new THREE.Object3D(), []);
const colorObj = useMemo(() => new THREE.Color(), []);
const halfWidth = baseWidth / 2;
const particles = useMemo(() => {
const arr: Particle[] = [];
for (let i = 0; i < COUNT; i++) {
arr.push({
position: new THREE.Vector3(
(Math.random() - 0.5) * baseWidth * 3,
Math.random() * (height + 5),
-halfWidth - 10 - Math.random() * 20
),
baseSpeed: 0.075 + Math.random() * 0.05,
wobbleSpeed: 0.5 + Math.random() * 1.5,
wobbleOffset: Math.random() * Math.PI * 2,
currentCpe: 0,
});
}
return arr;
}, [baseWidth, height]);
useEffect(() => {
if (meshRef.current) {
for (let i = 0; i < COUNT; i++) {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6');
meshRef.current.setColorAt(i, colorObj);
}
meshRef.current.instanceColor!.needsUpdate = true;
}
}, [isDark, colorObj]);
useFrame((state) => {
if (!meshRef.current) return;
const time = state.clock.elapsedTime;
particles.forEach((p, i) => {
const px = p.position.x;
const py = p.position.y;
const pz = p.position.z;
let currentSpeed = p.baseSpeed;
let localCpe = 0;
let deflectX = 0;
const inX = Math.abs(px) < halfWidth + 2;
const inZ = Math.abs(pz) < halfWidth + 2;
if (inX && inZ && py < height) {
localCpe = 0.8;
// Se a torre é densa (phi alto), o ar tenta desviar pelas laterais
if (pz < 0) {
const dist = halfWidth - Math.abs(px);
if (dist > 0) {
deflectX += (px > 0 ? 1 : -1) * currentSpeed * phi * 0.5;
}
}
}
p.currentCpe += (localCpe - p.currentCpe) * 0.1;
p.position.z += currentSpeed;
p.position.x += deflectX;
p.position.y += Math.sin(time * p.wobbleSpeed * 10 + p.wobbleOffset) * 0.01;
if (p.position.z > halfWidth + 10) {
p.position.z = -halfWidth - 10 - Math.random() * 5;
p.position.y = Math.random() * (height + 5);
p.position.x = (Math.random() - 0.5) * baseWidth * 3;
p.currentCpe = 0;
}
dummy.position.copy(p.position);
dummy.rotation.set(0, 0, 0);
dummy.rotateX(Math.PI / 2);
dummy.scale.set(1, 1 + currentSpeed * 6, 1);
dummy.updateMatrix();
meshRef.current!.setMatrixAt(i, dummy.matrix);
if (p.currentCpe > 0) {
colorObj.set(isDark ? '#f87171' : '#ef4444');
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj.set(isDark ? '#c084fc' : '#a855f7').lerp(new THREE.Color(isDark ? '#7e22ce' : '#6b21a8'), intensity);
} else {
colorObj.set(isDark ? '#60a5fa' : '#3b82f6');
}
meshRef.current!.setColorAt(i, colorObj);
});
meshRef.current.instanceMatrix.needsUpdate = true;
meshRef.current.instanceColor!.needsUpdate = true;
});
return (
<instancedMesh ref={meshRef} args={[undefined, undefined, COUNT]}>
<cylinderGeometry args={[0.04, 0.04, 1.0, 4]} />
<meshBasicMaterial transparent opacity={0.6} depthWrite={false} />
</instancedMesh>
);
};
+24
View File
@@ -175,6 +175,30 @@ function VaultModel({ span, length, rise, cpi, cpeProfile, cpeParallel, viewDire
<meshStandardMaterial color={isDark ? "#334155" : "#cbd5e1"} opacity={isAirflow ? 0.25 : 0.7} transparent side={THREE.DoubleSide} roughness={0.5} />
</mesh>
{/* Seta indicativa de direção do vento */}
<group position={[-span / 2 - 2.5, rise / 2, length / 2]} rotation={[0, 0, -Math.PI / 2]}>
<mesh castShadow>
<coneGeometry args={[0.3, 0.8, 16]} />
<meshStandardMaterial color="#3b82f6" roughness={0.3} />
</mesh>
<mesh position={[0, -0.6, 0]} castShadow>
<cylinderGeometry args={[0.1, 0.1, 1.2, 16]} />
<meshStandardMaterial color="#3b82f6" roughness={0.3} />
</mesh>
</group>
{/* Texto Vento */}
<Text
position={[-span / 2 - 2.5, rise / 2 + 1.2, length / 2]}
rotation={[0, 0, 0]}
fontSize={1.2}
color="#3b82f6"
anchorX="center"
anchorY="bottom"
>
Vento
</Text>
{/* Rótulo de dimensões */}
<Text
position={[0, rise + 1.5, length / 2]}
@@ -103,6 +103,8 @@ export function VaultAirflowSystem({ span, length, rise, viewDirection = 'perpen
p.currentCpe = 0.7; // Compressão a barlavento
} else if (px >= -span * 0.15 && px <= span * 0.15) {
p.currentCpe = -0.9; // Forte sucção no topo
} else if (px > span / 4) {
p.currentCpe = 0.5; // Sotavento / Esteira (agora positivo para ficar vermelho)
} else {
p.currentCpe = -0.5; // Sucção a sotavento
}
@@ -170,16 +172,12 @@ export function VaultAirflowSystem({ span, length, rise, viewDirection = 'perpen
// Colorir
if (p.currentCpe > 0) {
colorObj
.set(isDark ? '#ef4444' : '#dc2626')
.lerp(new THREE.Color(isDark ? '#fcd34d' : '#f59e0b'), 1 - Math.min(1, p.currentCpe));
colorObj.set(isDark ? '#f87171' : '#ef4444'); // Vermelho (Turbulento/Pressão)
} else if (p.currentCpe < 0) {
const intensity = Math.min(1, Math.abs(p.currentCpe));
colorObj
.set(isDark ? '#7dd3fc' : '#38bdf8')
.lerp(new THREE.Color(isDark ? '#1e40af' : '#1d4ed8'), intensity);
colorObj.set(isDark ? '#c084fc' : '#a855f7').lerp(new THREE.Color(isDark ? '#7e22ce' : '#6b21a8'), intensity); // Roxo (Acelerado/Sucção)
} else {
colorObj.set(isDark ? '#f1f5f9' : '#64748b');
colorObj.set(isDark ? '#60a5fa' : '#3b82f6'); // Azul (Corrente livre)
}
meshRef.current!.setColorAt(i, colorObj);
});