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