cosmo/frontend/src/components/Probe.tsx

218 lines
6.6 KiB
TypeScript
Raw Normal View History

/**
* Probe component - renders space probes with 3D models
*/
import { useRef, useMemo } from 'react';
import { Group } from 'three';
import { useGLTF, Html } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import type { CelestialBody } from '../types';
import { scalePosition } from '../utils/scaleDistance';
interface ProbeProps {
body: CelestialBody;
}
// Separate component for each probe type to properly use hooks
function ProbeModel({ body, modelPath }: { body: CelestialBody; modelPath: string }) {
const groupRef = useRef<Group>(null);
const position = body.positions[0];
// Apply non-linear distance scaling
const scaledPos = useMemo(() => {
const baseScaled = scalePosition(position.x, position.y, position.z);
const distance = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);
// Special handling for probes very close to planets (< 10 AU from Sun)
// These probes need visual offset to avoid overlapping with planets
if (distance < 10) {
// Add a radial offset to push the probe away from the Sun (and nearby planets)
// This makes probes like Juno visible next to Jupiter
const angle = Math.atan2(position.y, position.x);
const offsetAmount = 3.0; // Visual offset in scaled units
return {
x: baseScaled.x + Math.cos(angle) * offsetAmount,
y: baseScaled.y + Math.sin(angle) * offsetAmount,
z: baseScaled.z,
};
}
return baseScaled;
}, [position.x, position.y, position.z]);
// Load 3D model - must be at top level
const { scene } = useGLTF(modelPath);
// Configure model materials for proper rendering
const configuredScene = useMemo(() => {
const clonedScene = scene.clone();
clonedScene.traverse((child: any) => {
if (child.isMesh) {
// Force proper depth testing and high render order
child.renderOrder = 10000;
if (child.material) {
// Clone material to avoid modifying shared materials
if (Array.isArray(child.material)) {
child.material = child.material.map((mat: any) => {
const clonedMat = mat.clone();
clonedMat.depthTest = true;
clonedMat.depthWrite = true;
clonedMat.transparent = false;
clonedMat.opacity = 1.0;
clonedMat.alphaTest = 0;
clonedMat.needsUpdate = true;
return clonedMat;
});
} else {
child.material = child.material.clone();
child.material.depthTest = true;
child.material.depthWrite = true;
child.material.transparent = false;
child.material.opacity = 1.0;
child.material.alphaTest = 0;
child.material.needsUpdate = true;
}
}
}
});
return clonedScene;
}, [scene]);
// Slow rotation for visual effect
useFrame((_, delta) => {
if (groupRef.current) {
groupRef.current.rotation.y += delta * 0.2;
}
});
// Calculate ACTUAL distance from Sun (not scaled)
const distance = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);
return (
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]} ref={groupRef}>
<primitive
object={configuredScene}
scale={0.2}
/>
{/* Removed the semi-transparent sphere to avoid rendering conflicts */}
{/* Name label */}
<Html
position={[0, 1, 0]}
center
distanceFactor={15}
style={{
color: '#00ffff',
fontSize: '12px',
fontWeight: 'bold',
textShadow: '0 0 6px rgba(0,255,255,0.8)',
pointerEvents: 'none',
userSelect: 'none',
whiteSpace: 'nowrap',
}}
>
🛰 {body.name}
<br />
<span style={{ fontSize: '10px', opacity: 0.8 }}>
{distance.toFixed(2)} AU
</span>
</Html>
</group>
);
}
// Fallback component when model is not available
function ProbeFallback({ body }: { body: CelestialBody }) {
const position = body.positions[0];
// Apply non-linear distance scaling
const scaledPos = useMemo(() => {
const baseScaled = scalePosition(position.x, position.y, position.z);
const distance = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);
// Special handling for probes very close to planets (< 10 AU from Sun)
if (distance < 10) {
const angle = Math.atan2(position.y, position.x);
const offsetAmount = 3.0; // Visual offset in scaled units
return {
x: baseScaled.x + Math.cos(angle) * offsetAmount,
y: baseScaled.y + Math.sin(angle) * offsetAmount,
z: baseScaled.z,
};
}
return baseScaled;
}, [position.x, position.y, position.z]);
// Calculate ACTUAL distance from Sun (not scaled)
const distance = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);
return (
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]}>
<mesh>
<sphereGeometry args={[0.15, 16, 16]} />
<meshStandardMaterial color="#ff0000" emissive="#ff0000" emissiveIntensity={0.8} />
</mesh>
{/* Name label */}
<Html
position={[0, 1, 0]}
center
distanceFactor={15}
style={{
color: '#ff6666',
fontSize: '12px',
fontWeight: 'bold',
textShadow: '0 0 6px rgba(255,0,0,0.8)',
pointerEvents: 'none',
userSelect: 'none',
whiteSpace: 'nowrap',
}}
>
🛰 {body.name}
<br />
<span style={{ fontSize: '10px', opacity: 0.8 }}>
{distance.toFixed(2)} AU
</span>
</Html>
</group>
);
}
export function Probe({ body }: ProbeProps) {
const position = body.positions[0];
if (!position) return null;
// Model mapping for probes - match actual filenames
const modelMap: Record<string, string | null> = {
'Voyager 1': '/models/voyager_1.glb',
'Voyager 2': '/models/voyager_2.glb',
'Juno': '/models/juno.glb',
'Cassini': '/models/cassini.glb',
'New Horizons': null, // No model yet
'Parker Solar Probe': '/models/parker_solar_probe.glb',
'Perseverance': null, // No model yet
};
const modelPath = modelMap[body.name];
// Use model if available, otherwise use fallback
if (modelPath) {
return <ProbeModel body={body} modelPath={modelPath} />;
}
return <ProbeFallback body={body} />;
}
// Preload available models
const modelsToPreload = [
'/models/voyager_1.glb',
'/models/voyager_2.glb',
'/models/juno.glb',
'/models/cassini.glb',
'/models/parker_solar_probe.glb',
];
modelsToPreload.forEach((path) => {
useGLTF.preload(path);
});