222 lines
7.5 KiB
TypeScript
222 lines
7.5 KiB
TypeScript
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 {
|
|
condition: ShelterCondition;
|
|
depth: number;
|
|
width: number;
|
|
height: number;
|
|
theta: number;
|
|
backClosure: number;
|
|
leftClosure: number;
|
|
rightClosure: number;
|
|
openingPos: OpeningPosition;
|
|
backRange?: [number, number];
|
|
numColumns?: number;
|
|
windAngle?: ShelterWindAngle;
|
|
viewMode: '3d' | 'elevation' | 'airflow';
|
|
}
|
|
|
|
function ShelterModel({
|
|
condition: _condition,
|
|
depth,
|
|
width,
|
|
height,
|
|
theta,
|
|
backClosure,
|
|
leftClosure,
|
|
rightClosure,
|
|
openingPos,
|
|
backRange,
|
|
numColumns = 2,
|
|
windAngle: _windAngle = 0,
|
|
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);
|
|
|
|
// Paredes com abertura no topo ou na base (sem caixas/linhas brancas deslocadas)
|
|
const renderWallWithGap = (
|
|
w: number,
|
|
h: number,
|
|
closure: number,
|
|
pos: OpeningPosition,
|
|
rotY = 0,
|
|
position: [number, number, number] = [0, 0, 0],
|
|
customRange?: [number, number]
|
|
) => {
|
|
if (closure <= 0) return null;
|
|
|
|
let op = closure / 100;
|
|
let wallH = h * op;
|
|
let yPos = position[1] + wallH / 2;
|
|
|
|
if (customRange && closure < 100) {
|
|
const bottomPct = Math.max(0, Math.min(100, customRange[0])) / 100;
|
|
const topPct = Math.max(0, Math.min(100, customRange[1])) / 100;
|
|
op = Math.max(0.01, topPct - bottomPct);
|
|
wallH = h * op;
|
|
yPos = position[1] + h * bottomPct + wallH / 2;
|
|
} else if (pos === 'top' && closure < 100) {
|
|
// Parede na parte de baixo, fresta no topo
|
|
yPos = position[1] + wallH / 2;
|
|
} else if (pos === 'bottom' && closure < 100) {
|
|
// Parede suspensa na parte superior, vão na base
|
|
yPos = position[1] + h - wallH / 2;
|
|
}
|
|
|
|
return (
|
|
<group position={position} rotation={[0, rotY, 0]}>
|
|
<mesh position={[0, yPos - position[1], 0]}>
|
|
<boxGeometry args={[w, wallH, 0.15]} />
|
|
<meshStandardMaterial
|
|
color="#64748b"
|
|
transparent={op < 1}
|
|
opacity={Math.max(0.3, op)}
|
|
roughness={0.4}
|
|
/>
|
|
</mesh>
|
|
</group>
|
|
);
|
|
};
|
|
|
|
const overhang = 0.6;
|
|
const spanWidth = Math.max(0.1, width - 2 * overhang);
|
|
const spacing = spanWidth / Math.max(1, numColumns - 1);
|
|
const columnPositions = Array.from({ length: numColumns }).map(
|
|
(_, i) => -halfW + overhang + i * spacing
|
|
);
|
|
|
|
return (
|
|
<group position={[0, -height / 2, 0]}>
|
|
{/* 1. Estrutura Metálica Principal (Pilares e Vigas em Balanço - Tubos Quadrados) */}
|
|
{columnPositions.map((xPos, idx) => (
|
|
<group key={`frame-${idx}`} position={[xPos, 0, 0]}>
|
|
{/* Pilar traseiro (lado direito na elevação) */}
|
|
<mesh position={[0, height / 2, halfD - 0.25]}>
|
|
<boxGeometry args={[0.35, height, 0.35]} />
|
|
<meshStandardMaterial color="#cbd5e1" metalness={0.7} roughness={0.3} />
|
|
</mesh>
|
|
{/* Viga horizontal em balanço (TUBO QUAD. 150x150) */}
|
|
<group position={[0, height, 0]} rotation={[thetaRad, 0, 0]}>
|
|
<mesh position={[0, 0, 0]}>
|
|
<boxGeometry args={[0.25, 0.3, depth]} />
|
|
<meshStandardMaterial color="#cbd5e1" metalness={0.7} roughness={0.3} />
|
|
</mesh>
|
|
</group>
|
|
</group>
|
|
))}
|
|
|
|
{/* 2. Cobertura / Marquise */}
|
|
<group position={[0, height, 0]} rotation={[thetaRad, 0, 0]}>
|
|
<mesh position={[0, 0.15, 0]}>
|
|
<boxGeometry args={[width + 0.6, 0.15, depth + 0.4]} />
|
|
<meshStandardMaterial color="#3b82f6" roughness={0.3} metalness={0.4} />
|
|
</mesh>
|
|
</group>
|
|
|
|
{/* 3. Fechamentos de Fundo e Laterais (com suporte a fresta/abertura) */}
|
|
{/* Parede de Fundo (Traseira Z = +halfD) */}
|
|
{renderWallWithGap(width, height, backClosure, openingPos, 0, [0, 0, halfD], backRange)}
|
|
|
|
{/* Parede Lateral Esquerda (X = -halfW) */}
|
|
{renderWallWithGap(depth, height, leftClosure, 'distributed', Math.PI / 2, [-halfW, 0, 0])}
|
|
|
|
{/* Parede Lateral Direita (X = +halfW) */}
|
|
{renderWallWithGap(depth, height, rightClosure, 'distributed', Math.PI / 2, [halfW, 0, 0])}
|
|
|
|
{/* Seta de Vento Global (Sempre visível para não pular de posição) */}
|
|
<group>
|
|
<GlobalWindArrow
|
|
target={[0, height * 1.5 - 1.5, -halfD - 8 + 2]}
|
|
direction={[0, 0, 1]}
|
|
scale={3.2}
|
|
/>
|
|
<Text
|
|
position={[0, height * 1.5, -halfD - 8]}
|
|
rotation={[0, Math.PI, 0]}
|
|
fontSize={0.96}
|
|
color={isDark ? '#60a5fa' : '#2563eb'}
|
|
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) */}
|
|
{!isAirflow && (
|
|
<>
|
|
<Text position={[0, height + slopeRise + 1.4, 0]} rotation={[0, Math.PI, 0]} fontSize={0.45} color={isDark ? '#f8fafc' : '#0f172a'}>
|
|
{isElevation ? 'CORTE A-A (Elevação)' : 'Abrigo / Pórtico (NBR 6123)'}
|
|
</Text>
|
|
<Text position={[0, height + slopeRise + 0.6, 0]} rotation={[0, Math.PI, 0]} fontSize={0.51} color={isDark ? '#ff4d6d' : '#b91c1c'}>
|
|
Arrancamento [+Fz ↑]
|
|
</Text>
|
|
<Text position={[0, height * 0.5, halfD + 1.2]} rotation={[0, Math.PI, 0]} fontSize={0.51} color={isDark ? '#ff9f43' : '#c2410c'}>
|
|
Empuxo no Fundo [+Fx →]
|
|
</Text>
|
|
</>
|
|
)}
|
|
</group>
|
|
);
|
|
}
|
|
|
|
export function Shelter3D(props: Shelter3DProps) {
|
|
const theme = useCanvasTheme();
|
|
const isDark = theme === 'dark';
|
|
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>}
|
|
>
|
|
<ambientLight intensity={isDark ? 0.4 : 0.7} />
|
|
<directionalLight position={[10, 20, 15]} intensity={isDark ? 1.0 : 1.3} castShadow />
|
|
<ShelterModel {...props} />
|
|
<Grid
|
|
infiniteGrid
|
|
fadeDistance={Math.max(width, depth) * 3}
|
|
sectionColor={isDark ? '#475569' : '#94a3b8'}
|
|
cellColor={isDark ? '#1e293b' : '#cbd5e1'}
|
|
position={[0, -height / 2 - 0.01, 0]}
|
|
/>
|
|
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2 - 0.05} target={[0, 0, 0]} />
|
|
<Environment preset="city" />
|
|
</SceneCanvas>
|
|
);
|
|
}
|
|
|
|
export default Shelter3D;
|