218 lines
6.6 KiB
TypeScript
218 lines
6.6 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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);
|
|||
|
|
});
|