cosmo/frontend/src/components/Probe.tsx

220 lines
6.8 KiB
TypeScript
Raw Normal View History

/**
* Probe component - renders space probes with 3D models
*/
2025-11-27 10:14:25 +00:00
import { useRef, useMemo, useState, useEffect } 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;
}
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();
};
// 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}
<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}
<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 10:14:25 +00:00
if (!position) return null;
if (isLoading) return null; // Wait for model map to load
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);
});
});