223 lines
8.0 KiB
TypeScript
223 lines
8.0 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';
|
|
import { DomeAirflowSystem } from './DomeAirflowSystem';
|
|
|
|
export interface Dome3DInput {
|
|
diameter: number;
|
|
rise: number;
|
|
wallHeight: number;
|
|
cpi: number;
|
|
cpeBarlavento: number;
|
|
cpeTopo: number;
|
|
cpeLateral: number;
|
|
viewMode?: 'solid' | 'airflow';
|
|
}
|
|
|
|
function domeColor(cpe: number, cpi: number, isDark: boolean): THREE.Color {
|
|
const p = cpe - cpi;
|
|
const intensity = Math.min(1, Math.abs(p) / 1.5);
|
|
const l = isDark ? (65 - intensity * 15) : (60 - intensity * 25);
|
|
if (p > 0) {
|
|
return new THREE.Color(`hsl(${215 - intensity * 10}, ${70 + intensity * 25}%, ${Math.max(35, l)}%)`);
|
|
}
|
|
return new THREE.Color(`hsl(0, ${70 + intensity * 25}%, ${Math.max(40, l + 5)}%)`);
|
|
}
|
|
|
|
function DomeModel({ diameter, rise, wallHeight, cpi, cpeBarlavento, cpeTopo, cpeLateral, viewMode = 'solid' }: Dome3DInput) {
|
|
const theme = useCanvasTheme();
|
|
const isDark = theme === 'dark';
|
|
const isAirflow = viewMode === 'airflow';
|
|
const radius = diameter / 2;
|
|
const segments = 64;
|
|
|
|
// Cúpula (casca esférica) — gerada por segmentos de 0° a 360° para fechar o domo
|
|
const domeGeoms = useMemo(() => {
|
|
const arr: { startTheta: number; endTheta: number; color: THREE.Color }[] = [];
|
|
// Divide a circunferência completa (360°) em 6 zonas (simétricas)
|
|
const zones = [
|
|
{ fromDeg: 0, toDeg: 60, cpe: cpeBarlavento },
|
|
{ fromDeg: 60, toDeg: 120, cpe: cpeTopo },
|
|
{ fromDeg: 120, toDeg: 180, cpe: cpeLateral },
|
|
{ fromDeg: 180, toDeg: 240, cpe: cpeLateral },
|
|
{ fromDeg: 240, toDeg: 300, cpe: cpeTopo },
|
|
{ fromDeg: 300, toDeg: 360, cpe: cpeBarlavento },
|
|
];
|
|
for (const z of zones) {
|
|
arr.push({
|
|
startTheta: (z.fromDeg * Math.PI) / 180,
|
|
endTheta: (z.toDeg * Math.PI) / 180,
|
|
color: domeColor(z.cpe, cpi, isDark),
|
|
});
|
|
}
|
|
return arr;
|
|
}, [cpeBarlavento, cpeTopo, cpeLateral, cpi, isDark]);
|
|
|
|
// Raio da esfera da calota esférica baseada na flecha (rise) e raio da base (radius)
|
|
const rSphere = useMemo(() => {
|
|
return (radius * radius + rise * rise) / (2 * rise);
|
|
}, [radius, rise]);
|
|
|
|
return (
|
|
<group>
|
|
{/* Parede cilíndrica inferior */}
|
|
<mesh position={[0, wallHeight / 2, 0]} castShadow receiveShadow>
|
|
<cylinderGeometry args={[radius, radius, wallHeight, segments, 1, false]} />
|
|
<meshStandardMaterial color="#cbd5e1" opacity={isAirflow ? 0.25 : 0.8} transparent roughness={0.4} />
|
|
</mesh>
|
|
|
|
{/* Detalhes de anéis metálicos nas bordas */}
|
|
<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>
|
|
<mesh position={[0, wallHeight, 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>
|
|
|
|
{/* Cúpula de cobertura (Spherical Cap) segmentada */}
|
|
{domeGeoms.map((zone, idx) => {
|
|
const phiSteps = 16;
|
|
const segments2 = 16;
|
|
const vertices: number[] = [];
|
|
const normals: number[] = [];
|
|
const indices: number[] = [];
|
|
|
|
const phiStart = zone.startTheta;
|
|
const phiRange = zone.endTheta - zone.startTheta;
|
|
|
|
for (let i = 0; i <= phiSteps; i++) {
|
|
const phi = phiStart + (i / phiSteps) * phiRange;
|
|
for (let j = 0; j <= segments2; j++) {
|
|
const t = j / segments2;
|
|
const y = t * rise;
|
|
// Equação da esfera da calota
|
|
const yLocal = (rSphere - rise) + y;
|
|
const r = Math.sqrt(Math.max(0, rSphere * rSphere - yLocal * yLocal));
|
|
|
|
const x = r * Math.cos(phi);
|
|
const z = r * Math.sin(phi);
|
|
|
|
vertices.push(x, wallHeight + y, z);
|
|
|
|
// Normal analítica perfeita da esfera
|
|
normals.push(x / rSphere, yLocal / rSphere, z / rSphere);
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < phiSteps; i++) {
|
|
for (let j = 0; j < segments2; j++) {
|
|
const a = i * (segments2 + 1) + j;
|
|
const b = (i + 1) * (segments2 + 1) + j;
|
|
const c = (i + 1) * (segments2 + 1) + (j + 1);
|
|
const d = i * (segments2 + 1) + (j + 1);
|
|
indices.push(a, b, c, a, c, d);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<mesh key={idx} castShadow receiveShadow>
|
|
<bufferGeometry>
|
|
<bufferAttribute attach="attributes-position" args={[new Float32Array(vertices), 3]} />
|
|
<bufferAttribute attach="attributes-normal" args={[new Float32Array(normals), 3]} />
|
|
<bufferAttribute attach="index" args={[new Uint16Array(indices), 1]} />
|
|
</bufferGeometry>
|
|
<meshStandardMaterial color={zone.color} opacity={isAirflow ? 0.35 : 0.92} transparent roughness={0.4} side={THREE.DoubleSide} />
|
|
</mesh>
|
|
);
|
|
})}
|
|
|
|
{/* Seta indicativa de direção do vento */}
|
|
<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} />
|
|
</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={[radius + 2.5, wallHeight / 2 + 1.2, 0]}
|
|
rotation={[0, 0, 0]}
|
|
fontSize={1.2}
|
|
color="#3b82f6"
|
|
anchorX="center"
|
|
anchorY="bottom"
|
|
>
|
|
Vento
|
|
</Text>
|
|
|
|
{/* Texto informativo */}
|
|
<Text
|
|
position={[0, wallHeight + rise + 0.8, 0]}
|
|
fontSize={Math.max(0.35, Math.min(0.7, (diameter / 12) * 1.3))}
|
|
color={isDark ? "#cbd5e1" : "#1a202c"}
|
|
anchorX="center"
|
|
anchorY="bottom"
|
|
>
|
|
Diâm = {diameter}m | Flecha = {rise}m
|
|
</Text>
|
|
</group>
|
|
);
|
|
}
|
|
|
|
export default function Dome3DViewer(props: Dome3DInput) {
|
|
const theme = useCanvasTheme();
|
|
const isDark = theme === 'dark';
|
|
const { viewMode = 'solid' } = props;
|
|
|
|
const fallback = (
|
|
<FallbackDiagram
|
|
type="dome"
|
|
props={props}
|
|
/>
|
|
);
|
|
|
|
const maxDim = Math.max(props.diameter, props.wallHeight + props.rise);
|
|
|
|
return (
|
|
<SceneCanvas moduleId="cupula"
|
|
shadows
|
|
frameloop={viewMode === 'airflow' ? 'always' : 'demand'}
|
|
gl={{ preserveDrawingBuffer: true, antialias: true }}
|
|
camera={{ position: [props.diameter * 1.5, (props.wallHeight + props.rise) * 1.5, props.diameter * 1.5], fov: 40 }}
|
|
fallback={fallback}
|
|
>
|
|
<ambientLight intensity={0.7} />
|
|
<directionalLight
|
|
position={[props.diameter * 1.5, (props.wallHeight + props.rise) * 2.5, props.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}
|
|
/>
|
|
<DomeModel {...props} />
|
|
{viewMode === 'airflow' && (
|
|
<DomeAirflowSystem diameter={props.diameter} rise={props.rise} wallHeight={props.wallHeight} />
|
|
)}
|
|
<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>
|
|
);
|
|
} |