2025-11-27 05:16:19 +00:00
|
|
|
/**
|
|
|
|
|
* CelestialBody component - renders a planet or probe with textures
|
|
|
|
|
*/
|
2025-11-29 15:10:00 +00:00
|
|
|
import { useRef, useMemo, useState, useEffect } from 'react';
|
2025-11-27 05:16:19 +00:00
|
|
|
import { Mesh, DoubleSide } from 'three';
|
|
|
|
|
import { useFrame } from '@react-three/fiber';
|
|
|
|
|
import { useTexture, Html } from '@react-three/drei';
|
|
|
|
|
import type { CelestialBody as CelestialBodyType } from '../types';
|
2025-11-29 15:10:00 +00:00
|
|
|
import { calculateRenderPosition, getOffsetDescription } from '../utils/renderPosition';
|
|
|
|
|
import { fetchBodyResources } from '../utils/api';
|
2025-11-30 15:04:04 +00:00
|
|
|
import { PLANET_SIZES, SATELLITE_SIZES, getCelestialSize } from '../config/celestialSizes';
|
2025-11-27 05:16:19 +00:00
|
|
|
|
|
|
|
|
interface CelestialBodyProps {
|
|
|
|
|
body: CelestialBodyType;
|
2025-11-29 15:10:00 +00:00
|
|
|
allBodies: CelestialBodyType[];
|
2025-11-30 15:04:04 +00:00
|
|
|
isSelected?: boolean;
|
2025-11-27 05:16:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Saturn Rings component - multiple rings for band effect
|
|
|
|
|
function SaturnRings() {
|
|
|
|
|
return (
|
|
|
|
|
<group rotation={[Math.PI / 2, 0, 0]}>
|
|
|
|
|
{/* Inner bright ring */}
|
|
|
|
|
<mesh>
|
|
|
|
|
<ringGeometry args={[1.4, 1.6, 64]} />
|
|
|
|
|
<meshBasicMaterial
|
|
|
|
|
color="#D4B896"
|
|
|
|
|
transparent
|
|
|
|
|
opacity={0.7}
|
|
|
|
|
side={DoubleSide}
|
|
|
|
|
/>
|
|
|
|
|
</mesh>
|
|
|
|
|
{/* Middle darker band */}
|
|
|
|
|
<mesh>
|
|
|
|
|
<ringGeometry args={[1.6, 1.75, 64]} />
|
|
|
|
|
<meshBasicMaterial
|
|
|
|
|
color="#8B7355"
|
|
|
|
|
transparent
|
|
|
|
|
opacity={0.5}
|
|
|
|
|
side={DoubleSide}
|
|
|
|
|
/>
|
|
|
|
|
</mesh>
|
|
|
|
|
{/* Outer bright ring */}
|
|
|
|
|
<mesh>
|
|
|
|
|
<ringGeometry args={[1.75, 2.0, 64]} />
|
|
|
|
|
<meshBasicMaterial
|
|
|
|
|
color="#C4A582"
|
|
|
|
|
transparent
|
|
|
|
|
opacity={0.6}
|
|
|
|
|
side={DoubleSide}
|
|
|
|
|
/>
|
|
|
|
|
</mesh>
|
|
|
|
|
{/* Cassini Division (gap) */}
|
|
|
|
|
<mesh>
|
|
|
|
|
<ringGeometry args={[2.0, 2.05, 64]} />
|
|
|
|
|
<meshBasicMaterial
|
|
|
|
|
color="#000000"
|
|
|
|
|
transparent
|
|
|
|
|
opacity={0.2}
|
|
|
|
|
side={DoubleSide}
|
|
|
|
|
/>
|
|
|
|
|
</mesh>
|
|
|
|
|
{/* A Ring (outer) */}
|
|
|
|
|
<mesh>
|
|
|
|
|
<ringGeometry args={[2.05, 2.2, 64]} />
|
|
|
|
|
<meshBasicMaterial
|
|
|
|
|
color="#B89968"
|
|
|
|
|
transparent
|
|
|
|
|
opacity={0.5}
|
|
|
|
|
side={DoubleSide}
|
|
|
|
|
/>
|
|
|
|
|
</mesh>
|
|
|
|
|
</group>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Planet component with texture
|
2025-11-30 15:04:04 +00:00
|
|
|
function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected = false }: {
|
2025-11-27 05:16:19 +00:00
|
|
|
body: CelestialBodyType;
|
|
|
|
|
size: number;
|
|
|
|
|
emissive: string;
|
|
|
|
|
emissiveIntensity: number;
|
2025-11-29 15:10:00 +00:00
|
|
|
allBodies: CelestialBodyType[];
|
2025-11-30 15:04:04 +00:00
|
|
|
isSelected?: boolean;
|
2025-11-27 05:16:19 +00:00
|
|
|
}) {
|
|
|
|
|
const meshRef = useRef<Mesh>(null);
|
|
|
|
|
const position = body.positions[0];
|
2025-11-29 15:10:00 +00:00
|
|
|
const [texturePath, setTexturePath] = useState<string | null | undefined>(undefined);
|
2025-11-27 05:16:19 +00:00
|
|
|
|
2025-11-29 15:10:00 +00:00
|
|
|
// Use smart render position calculation
|
|
|
|
|
const renderPosition = useMemo(() => {
|
|
|
|
|
return calculateRenderPosition(body, allBodies);
|
|
|
|
|
}, [position.x, position.y, position.z, body, allBodies]);
|
|
|
|
|
|
|
|
|
|
const scaledPos = { x: renderPosition.x, y: renderPosition.y, z: renderPosition.z };
|
|
|
|
|
|
|
|
|
|
// Fetch texture from backend API
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetchBodyResources(body.id, 'texture')
|
|
|
|
|
.then((response) => {
|
|
|
|
|
// Find the main texture (not atmosphere or night layers)
|
|
|
|
|
const mainTexture = response.resources.find(
|
|
|
|
|
(r) => !r.file_path.includes('atmosphere') && !r.file_path.includes('night')
|
|
|
|
|
);
|
|
|
|
|
if (mainTexture) {
|
|
|
|
|
// Construct full URL from file_path
|
|
|
|
|
// file_path is like "texture/2k_sun.jpg", need to add "upload/" prefix
|
|
|
|
|
const protocol = window.location.protocol;
|
|
|
|
|
const hostname = window.location.hostname;
|
|
|
|
|
const port = import.meta.env.VITE_API_BASE_URL ? '' : ':8000';
|
|
|
|
|
setTexturePath(`${protocol}//${hostname}${port}/upload/${mainTexture.file_path}`);
|
|
|
|
|
} else {
|
|
|
|
|
setTexturePath(null);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
console.error(`Failed to load texture for ${body.name}:`, err);
|
|
|
|
|
setTexturePath(null);
|
|
|
|
|
});
|
|
|
|
|
}, [body.id, body.name]);
|
2025-11-27 05:16:19 +00:00
|
|
|
|
2025-11-29 15:10:00 +00:00
|
|
|
// Show nothing while loading
|
|
|
|
|
if (texturePath === undefined) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return <PlanetMesh
|
|
|
|
|
body={body}
|
|
|
|
|
size={size}
|
|
|
|
|
emissive={emissive}
|
|
|
|
|
emissiveIntensity={emissiveIntensity}
|
|
|
|
|
scaledPos={scaledPos}
|
|
|
|
|
texturePath={texturePath}
|
|
|
|
|
position={position}
|
|
|
|
|
meshRef={meshRef}
|
|
|
|
|
hasOffset={renderPosition.hasOffset}
|
|
|
|
|
allBodies={allBodies}
|
2025-11-30 15:04:04 +00:00
|
|
|
isSelected={isSelected}
|
2025-11-29 15:10:00 +00:00
|
|
|
/>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Separate component to handle texture loading
|
2025-11-30 15:04:04 +00:00
|
|
|
function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, texturePath, position, meshRef, hasOffset, allBodies, isSelected = false }: {
|
2025-11-29 15:10:00 +00:00
|
|
|
body: CelestialBodyType;
|
|
|
|
|
size: number;
|
|
|
|
|
emissive: string;
|
|
|
|
|
emissiveIntensity: number;
|
|
|
|
|
scaledPos: { x: number; y: number; z: number };
|
|
|
|
|
texturePath: string | null;
|
|
|
|
|
position: { x: number; y: number; z: number };
|
|
|
|
|
meshRef: React.RefObject<Mesh>;
|
|
|
|
|
hasOffset: boolean;
|
|
|
|
|
allBodies: CelestialBodyType[];
|
2025-11-30 15:04:04 +00:00
|
|
|
isSelected?: boolean;
|
2025-11-29 15:10:00 +00:00
|
|
|
}) {
|
|
|
|
|
// Load texture if path is provided
|
2025-11-27 05:16:19 +00:00
|
|
|
const texture = texturePath ? useTexture(texturePath) : null;
|
|
|
|
|
|
|
|
|
|
// Slow rotation for visual effect
|
|
|
|
|
useFrame((_, delta) => {
|
|
|
|
|
if (meshRef.current) {
|
|
|
|
|
meshRef.current.rotation.y += delta * 0.1;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Calculate ACTUAL distance from Sun for display (not scaled)
|
|
|
|
|
const distance = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);
|
|
|
|
|
|
2025-11-29 15:10:00 +00:00
|
|
|
// Get offset description if this body has one
|
|
|
|
|
const offsetDesc = hasOffset ? getOffsetDescription(body, allBodies) : null;
|
|
|
|
|
|
2025-11-27 05:16:19 +00:00
|
|
|
return (
|
|
|
|
|
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]}>
|
|
|
|
|
<mesh ref={meshRef} renderOrder={0}>
|
|
|
|
|
<sphereGeometry args={[size, 64, 64]} />
|
|
|
|
|
{texture ? (
|
|
|
|
|
<meshStandardMaterial
|
|
|
|
|
map={texture}
|
|
|
|
|
emissive={emissive}
|
|
|
|
|
emissiveIntensity={emissiveIntensity}
|
|
|
|
|
roughness={body.type === 'star' ? 0 : 0.7}
|
|
|
|
|
metalness={0.1}
|
|
|
|
|
depthTest={true}
|
|
|
|
|
depthWrite={true}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<meshStandardMaterial
|
|
|
|
|
color="#888888"
|
|
|
|
|
emissive={emissive}
|
|
|
|
|
emissiveIntensity={emissiveIntensity}
|
|
|
|
|
roughness={0.7}
|
|
|
|
|
metalness={0.1}
|
|
|
|
|
depthTest={true}
|
|
|
|
|
depthWrite={true}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</mesh>
|
|
|
|
|
|
|
|
|
|
{/* Saturn Rings */}
|
2025-11-29 16:58:58 +00:00
|
|
|
{body.id === '699' && <SaturnRings />}
|
2025-11-27 05:16:19 +00:00
|
|
|
|
|
|
|
|
{/* Sun glow effect */}
|
|
|
|
|
{body.type === 'star' && (
|
|
|
|
|
<>
|
|
|
|
|
<pointLight intensity={10} distance={400} color="#fff8e7" />
|
|
|
|
|
<mesh>
|
|
|
|
|
<sphereGeometry args={[size * 1.8, 32, 32]} />
|
|
|
|
|
<meshBasicMaterial color="#FDB813" transparent opacity={0.35} />
|
|
|
|
|
</mesh>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Name label */}
|
|
|
|
|
<Html
|
|
|
|
|
position={[0, size + 0.3, 0]}
|
|
|
|
|
center
|
|
|
|
|
distanceFactor={10}
|
|
|
|
|
style={{
|
|
|
|
|
color: body.type === 'star' ? '#FDB813' : '#ffffff',
|
2025-11-30 15:04:04 +00:00
|
|
|
fontSize: '9px', // 从 11px 减小到 9px
|
2025-11-27 05:16:19 +00:00
|
|
|
fontWeight: 'bold',
|
|
|
|
|
textShadow: '0 0 4px rgba(0,0,0,0.8)',
|
|
|
|
|
pointerEvents: 'none',
|
|
|
|
|
userSelect: 'none',
|
|
|
|
|
whiteSpace: 'nowrap',
|
2025-11-30 15:04:04 +00:00
|
|
|
opacity: isSelected ? 1 : 0.4, // 未选中时更暗淡
|
|
|
|
|
transition: 'opacity 0.3s ease',
|
2025-11-27 05:16:19 +00:00
|
|
|
}}
|
|
|
|
|
>
|
2025-11-27 10:14:25 +00:00
|
|
|
{body.name_zh || body.name}
|
2025-11-29 15:10:00 +00:00
|
|
|
{offsetDesc && (
|
|
|
|
|
<>
|
|
|
|
|
<br />
|
2025-11-30 15:04:04 +00:00
|
|
|
<span style={{ fontSize: '7px', color: '#ffaa00', opacity: isSelected ? 0.9 : 0.5 }}> {/* 从 9px 减小到 7px */}
|
2025-11-29 15:10:00 +00:00
|
|
|
{offsetDesc}
|
|
|
|
|
</span>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2025-11-27 05:16:19 +00:00
|
|
|
<br />
|
2025-11-30 15:04:04 +00:00
|
|
|
<span style={{ fontSize: '7px', opacity: isSelected ? 0.7 : 0.3 }}> {/* 从 8px 减小到 7px */}
|
2025-11-27 05:16:19 +00:00
|
|
|
{distance.toFixed(2)} AU
|
|
|
|
|
</span>
|
|
|
|
|
</Html>
|
|
|
|
|
</group>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-30 15:04:04 +00:00
|
|
|
export function CelestialBody({ body, allBodies, isSelected = false }: CelestialBodyProps) {
|
2025-11-27 05:16:19 +00:00
|
|
|
// Get the current position (use the first position for now)
|
|
|
|
|
const position = body.positions[0];
|
|
|
|
|
if (!position) return null;
|
|
|
|
|
|
|
|
|
|
// Skip probes - they will use 3D models
|
|
|
|
|
if (body.type === 'probe') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Determine size based on body type
|
|
|
|
|
const appearance = useMemo(() => {
|
|
|
|
|
if (body.type === 'star') {
|
|
|
|
|
return {
|
2025-11-30 15:04:04 +00:00
|
|
|
size: 0.4, // Sun size
|
2025-11-27 05:16:19 +00:00
|
|
|
emissive: '#FDB813',
|
|
|
|
|
emissiveIntensity: 1.5,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-29 15:10:00 +00:00
|
|
|
// Satellite (natural moons) - small size with slight glow for visibility
|
|
|
|
|
if (body.type === 'satellite') {
|
|
|
|
|
return {
|
2025-11-30 15:04:04 +00:00
|
|
|
size: getCelestialSize(body.name, body.type),
|
2025-11-29 15:10:00 +00:00
|
|
|
emissive: '#888888', // Slight glow to make it visible
|
|
|
|
|
emissiveIntensity: 0.4,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-30 15:04:04 +00:00
|
|
|
// Planet and dwarf planet sizes
|
2025-11-27 05:16:19 +00:00
|
|
|
return {
|
2025-11-30 15:04:04 +00:00
|
|
|
size: getCelestialSize(body.name, body.type),
|
2025-11-27 05:16:19 +00:00
|
|
|
emissive: '#000000',
|
|
|
|
|
emissiveIntensity: 0,
|
|
|
|
|
};
|
|
|
|
|
}, [body.name, body.type]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Planet
|
|
|
|
|
body={body}
|
|
|
|
|
size={appearance.size}
|
|
|
|
|
emissive={appearance.emissive}
|
|
|
|
|
emissiveIntensity={appearance.emissiveIntensity}
|
2025-11-29 15:10:00 +00:00
|
|
|
allBodies={allBodies}
|
2025-11-30 15:04:04 +00:00
|
|
|
isSelected={isSelected}
|
2025-11-27 05:16:19 +00:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|