216 lines
7.4 KiB
TypeScript
216 lines
7.4 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { Grid, Environment, Text } from '@react-three/drei';
|
|
import { ViewerOrbitControls as OrbitControls } from './ViewerOrbitControls';
|
|
import * as THREE from 'three';
|
|
import SceneCanvas from '../SceneCanvas';
|
|
import FallbackDiagram from '../FallbackDiagram';
|
|
import { useCanvasTheme } from '@/lib/theme';
|
|
|
|
export interface Cylinder3DInput {
|
|
diameter: number;
|
|
height: number;
|
|
/** Cpe profile ao longo da circunferência (0° a 180°) */
|
|
cpeProfile: { angle: number; cpe: number }[];
|
|
cpi: number;
|
|
}
|
|
|
|
function cylinderColor(cpe: number, cpi: number): THREE.Color {
|
|
const p = cpe - cpi;
|
|
const intensity = Math.min(1, Math.abs(p) / 1.2);
|
|
if (p > 0) {
|
|
return new THREE.Color(`hsl(${215 - intensity * 10}, ${70 + intensity * 25}%, ${Math.max(35, 65 - intensity * 25)}%)`);
|
|
}
|
|
return new THREE.Color(`hsl(0, ${70 + intensity * 25}%, ${Math.max(40, 65 - intensity * 20)}%)`);
|
|
}
|
|
|
|
function CylinderModel({ diameter, height, cpeProfile, cpi }: Cylinder3DInput) {
|
|
const theme = useCanvasTheme();
|
|
const isDark = theme === 'dark';
|
|
const segments = 64;
|
|
const radius = diameter / 2;
|
|
|
|
// Espelha o cpeProfile para cobrir de 0° a 360°
|
|
const fullCpeProfile = useMemo(() => {
|
|
if (cpeProfile.length === 0) return [];
|
|
const arr = [...cpeProfile];
|
|
const step = cpeProfile.length > 1 ? cpeProfile[1].angle - cpeProfile[0].angle : 10;
|
|
|
|
// Espelha de 180° a 360°
|
|
for (let angle = 180 + step; angle < 360; angle += step) {
|
|
const mirroredAngle = 360 - angle;
|
|
const closest = cpeProfile.find(p => Math.abs(p.angle - mirroredAngle) < 0.1) || cpeProfile[cpeProfile.length - 1];
|
|
arr.push({ angle, cpe: closest.cpe });
|
|
}
|
|
// Fecha o ciclo em 360° (igual a 0°)
|
|
arr.push({ angle: 360, cpe: cpeProfile[0].cpe });
|
|
return arr;
|
|
}, [cpeProfile]);
|
|
|
|
// Cria faces individuais com cor independente por ângulo
|
|
const faces = useMemo(() => {
|
|
const arr: { angle: number; cpe: number; color: THREE.Color }[] = [];
|
|
for (let i = 0; i < fullCpeProfile.length - 1; i++) {
|
|
const a = fullCpeProfile[i];
|
|
const b = fullCpeProfile[i + 1];
|
|
const angleMid = (a.angle + b.angle) / 2;
|
|
const cpeMid = (a.cpe + b.cpe) / 2;
|
|
arr.push({ angle: angleMid, cpe: cpeMid, color: cylinderColor(cpeMid, cpi) });
|
|
}
|
|
return arr;
|
|
}, [fullCpeProfile, cpi]);
|
|
|
|
return (
|
|
<group>
|
|
{/* Paredes Verticais do Cilindro */}
|
|
{faces.map((face, idx) => {
|
|
if (fullCpeProfile.length <= idx + 1) return null;
|
|
const stepAngle = fullCpeProfile[1].angle - fullCpeProfile[0].angle;
|
|
const a0 = (face.angle - stepAngle / 2) * Math.PI / 180;
|
|
const a1 = (face.angle + stepAngle / 2) * Math.PI / 180;
|
|
const x0 = Math.cos(a0) * radius;
|
|
const z0 = Math.sin(a0) * radius;
|
|
const x1 = Math.cos(a1) * radius;
|
|
const z1 = Math.sin(a1) * radius;
|
|
|
|
// Normais dos vértices
|
|
const nx0 = Math.cos(a0);
|
|
const nz0 = Math.sin(a0);
|
|
const nx1 = Math.cos(a1);
|
|
const nz1 = Math.sin(a1);
|
|
|
|
// Array com os 6 vértices para formar dois triângulos (um quad completo)
|
|
const vertices = new Float32Array([
|
|
x0, 0, z0,
|
|
x1, 0, z1,
|
|
x1, height, z1,
|
|
|
|
x0, 0, z0,
|
|
x1, height, z1,
|
|
x0, height, z0,
|
|
]);
|
|
|
|
const normals = new Float32Array([
|
|
nx0, 0, nz0,
|
|
nx1, 0, nz1,
|
|
nx1, 0, nz1,
|
|
|
|
nx0, 0, nz0,
|
|
nx1, 0, nz1,
|
|
nx0, 0, nz0,
|
|
]);
|
|
|
|
return (
|
|
<mesh key={idx} castShadow receiveShadow>
|
|
<bufferGeometry>
|
|
<bufferAttribute
|
|
attach="attributes-position"
|
|
args={[vertices, 3]}
|
|
/>
|
|
<bufferAttribute
|
|
attach="attributes-normal"
|
|
args={[normals, 3]}
|
|
/>
|
|
</bufferGeometry>
|
|
<meshStandardMaterial color={face.color} opacity={0.9} transparent roughness={0.4} side={THREE.DoubleSide} />
|
|
</mesh>
|
|
);
|
|
})}
|
|
|
|
{/* Tampa superior sólida */}
|
|
<mesh position={[0, height, 0]} rotation={[-Math.PI / 2, 0, 0]} castShadow receiveShadow>
|
|
<circleGeometry args={[radius, segments]} />
|
|
<meshStandardMaterial color={cylinderColor(cpeProfile[cpeProfile.length - 1].cpe, cpi)} opacity={0.8} transparent side={THREE.DoubleSide} roughness={0.4} />
|
|
</mesh>
|
|
|
|
{/* Anéis de detalhe (bordas do cilindro) */}
|
|
<mesh position={[0, height + 0.01, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
|
<ringGeometry args={[radius - 0.03, radius + 0.03, segments]} />
|
|
<meshStandardMaterial color={isDark ? "#475569" : "#2d3748"} roughness={0.5} />
|
|
</mesh>
|
|
<mesh position={[0, 0.01, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
|
<ringGeometry args={[radius - 0.03, radius + 0.03, segments]} />
|
|
<meshStandardMaterial color={isDark ? "#475569" : "#2d3748"} roughness={0.5} />
|
|
</mesh>
|
|
|
|
{/* Seta indicativa de direção do vento */}
|
|
<group position={[-radius - 2.5, height / 2, 0]} 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>
|
|
<Text
|
|
position={[0, -1.5, 0]}
|
|
rotation={[Math.PI / 2, 0, 0]}
|
|
fontSize={0.4}
|
|
color="#3b82f6"
|
|
anchorX="center"
|
|
anchorY="middle"
|
|
>
|
|
Vento
|
|
</Text>
|
|
</group>
|
|
|
|
{/* Texto de Informação */}
|
|
<Text
|
|
position={[0, height + 0.8, 0]}
|
|
fontSize={Math.max(0.3, Math.min(0.6, diameter / 10))}
|
|
color={isDark ? "#cbd5e1" : "#1a202c"}
|
|
anchorX="center"
|
|
anchorY="bottom"
|
|
>
|
|
Alt = {height}m | Diâm = {diameter}m
|
|
</Text>
|
|
</group>
|
|
);
|
|
}
|
|
|
|
export default function Cylinder3DViewer({ diameter, height, cpeProfile, cpi }: Cylinder3DInput) {
|
|
const theme = useCanvasTheme();
|
|
const isDark = theme === 'dark';
|
|
|
|
const fallback = (
|
|
<FallbackDiagram
|
|
type="cylinder"
|
|
props={{ diameter, height, cpeProfile, cpi }}
|
|
/>
|
|
);
|
|
|
|
const maxDim = Math.max(diameter, height);
|
|
|
|
return (
|
|
<SceneCanvas moduleId="cilindro"
|
|
shadows
|
|
gl={{ preserveDrawingBuffer: true, antialias: true }}
|
|
camera={{ position: [diameter * 1.5, height * 1.2, diameter * 1.5], fov: 40 }}
|
|
fallback={fallback}
|
|
>
|
|
<ambientLight intensity={0.7} />
|
|
<directionalLight
|
|
position={[diameter * 1.5, height * 2.5, diameter * 1.5]}
|
|
intensity={1.2}
|
|
castShadow
|
|
shadow-mapSize-width={1024}
|
|
shadow-mapSize-height={1024}
|
|
shadow-camera-far={maxDim * 10}
|
|
shadow-camera-left={-maxDim}
|
|
shadow-camera-right={maxDim}
|
|
shadow-camera-top={maxDim}
|
|
shadow-camera-bottom={-maxDim}
|
|
/>
|
|
<CylinderModel diameter={diameter} height={height} cpeProfile={cpeProfile} cpi={cpi} />
|
|
<Grid
|
|
infiniteGrid
|
|
fadeDistance={maxDim * 5}
|
|
sectionColor={isDark ? "#475569" : "#94a3b8"}
|
|
cellColor={isDark ? "#1e293b" : "#cbd5e1"}
|
|
position={[0, -0.01, 0]}
|
|
/>
|
|
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2 - 0.05} />
|
|
<Environment preset="city" />
|
|
</SceneCanvas>
|
|
);
|
|
} |