2025-11-27 05:16:19 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Probe component - renders space probes with 3D models
|
|
|
|
|
|
*/
|
2025-11-27 10:14:25 +00:00
|
|
|
|
import { useRef, useMemo, useState, useEffect } from 'react';
|
2025-11-27 05:16:19 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-27 10:14:25 +00:00
|
|
|
|
// Load probe model mapping from data file
|
|
|
|
|
|
const loadProbeModels = async (): Promise<Record<string, string | null>> => {
|
|
|
|
|
|
const response = await fetch('/data/probe-models.json');
|
|
|
|
|
|
return response.json();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-27 05:16:19 +00:00
|
|
|
|
// 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',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2025-11-27 10:14:25 +00:00
|
|
|
|
🛰️ {body.name_zh || body.name}
|
2025-11-27 05:16:19 +00:00
|
|
|
|
<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',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2025-11-27 10:14:25 +00:00
|
|
|
|
🛰️ {body.name_zh || body.name}
|
2025-11-27 05:16:19 +00:00
|
|
|
|
<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];
|
2025-11-27 10:14:25 +00:00
|
|
|
|
const [modelMap, setModelMap] = useState<Record<string, string | null>>({});
|
|
|
|
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
|
|
|
|
|
|
|
|
// Load model mapping on mount
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadProbeModels().then((data) => {
|
|
|
|
|
|
setModelMap(data);
|
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
}, []);
|
2025-11-27 05:16:19 +00:00
|
|
|
|
|
2025-11-27 10:14:25 +00:00
|
|
|
|
if (!position) return null;
|
|
|
|
|
|
if (isLoading) return null; // Wait for model map to load
|
2025-11-27 05:16:19 +00:00
|
|
|
|
|
|
|
|
|
|
const modelPath = modelMap[body.name];
|
|
|
|
|
|
|
|
|
|
|
|
// Use model if available, otherwise use fallback
|
|
|
|
|
|
if (modelPath) {
|
|
|
|
|
|
return <ProbeModel body={body} modelPath={modelPath} />;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return <ProbeFallback body={body} />;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-27 10:14:25 +00:00
|
|
|
|
// Preload available models from data file
|
|
|
|
|
|
loadProbeModels().then((modelMap) => {
|
|
|
|
|
|
const modelsToPreload = Object.values(modelMap).filter((path): path is string => path !== null);
|
|
|
|
|
|
modelsToPreload.forEach((path) => {
|
|
|
|
|
|
useGLTF.preload(path);
|
|
|
|
|
|
});
|
2025-11-27 05:16:19 +00:00
|
|
|
|
});
|