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';
|
2025-11-29 15:10:00 +00:00
|
|
|
|
import * as THREE from 'three';
|
2025-11-27 05:16:19 +00:00
|
|
|
|
import { useGLTF, Html } from '@react-three/drei';
|
|
|
|
|
|
import { useFrame } from '@react-three/fiber';
|
|
|
|
|
|
import type { CelestialBody } from '../types';
|
2025-11-29 15:10:00 +00:00
|
|
|
|
import { calculateRenderPosition, getOffsetDescription } from '../utils/renderPosition';
|
|
|
|
|
|
import { fetchBodyResources } from '../utils/api';
|
2025-11-27 05:16:19 +00:00
|
|
|
|
|
|
|
|
|
|
interface ProbeProps {
|
|
|
|
|
|
body: CelestialBody;
|
2025-11-29 15:10:00 +00:00
|
|
|
|
allBodies: CelestialBody[];
|
2025-11-27 05:16:19 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Separate component for each probe type to properly use hooks
|
2025-11-29 15:10:00 +00:00
|
|
|
|
function ProbeModel({ body, modelPath, allBodies, onError }: {
|
|
|
|
|
|
body: CelestialBody;
|
|
|
|
|
|
modelPath: string;
|
|
|
|
|
|
allBodies: CelestialBody[];
|
|
|
|
|
|
onError: () => void;
|
|
|
|
|
|
}) {
|
2025-11-27 05:16:19 +00:00
|
|
|
|
const groupRef = useRef<Group>(null);
|
|
|
|
|
|
const position = body.positions[0];
|
|
|
|
|
|
|
2025-11-30 02:43:47 +00:00
|
|
|
|
// 1. Hook: Render Position
|
2025-11-29 15:10:00 +00:00
|
|
|
|
const renderPosition = useMemo(() => {
|
|
|
|
|
|
return calculateRenderPosition(body, allBodies);
|
|
|
|
|
|
}, [position.x, position.y, position.z, body, allBodies]);
|
2025-11-27 05:16:19 +00:00
|
|
|
|
|
2025-11-29 15:10:00 +00:00
|
|
|
|
const scaledPos = { x: renderPosition.x, y: renderPosition.y, z: renderPosition.z };
|
2025-11-27 05:16:19 +00:00
|
|
|
|
|
2025-11-30 02:43:47 +00:00
|
|
|
|
// 2. Hook: Load GLTF
|
|
|
|
|
|
// We removed the try-catch block because calling hooks conditionally or inside try-catch is forbidden.
|
|
|
|
|
|
// If useGLTF fails, it will throw an error which should be caught by an ErrorBoundary or handled by Suspense.
|
|
|
|
|
|
// Since we preload in the parent, this should generally be safe.
|
|
|
|
|
|
const gltf = useGLTF(modelPath);
|
|
|
|
|
|
const scene = gltf.scene;
|
|
|
|
|
|
|
|
|
|
|
|
// 3. Hook: Optimal Scale
|
2025-11-29 15:10:00 +00:00
|
|
|
|
const optimalScale = useMemo(() => {
|
2025-11-30 02:43:47 +00:00
|
|
|
|
if (!scene) return 1;
|
|
|
|
|
|
|
2025-11-29 15:10:00 +00:00
|
|
|
|
// Calculate bounding box to determine model size
|
|
|
|
|
|
const box = new THREE.Box3().setFromObject(scene);
|
|
|
|
|
|
const size = new THREE.Vector3();
|
|
|
|
|
|
box.getSize(size);
|
|
|
|
|
|
|
|
|
|
|
|
// Get the largest dimension
|
|
|
|
|
|
const maxDimension = Math.max(size.x, size.y, size.z);
|
|
|
|
|
|
|
|
|
|
|
|
// Target size for display (consistent visual size)
|
|
|
|
|
|
const targetSize = 0.5; // Target visual size in scene units
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate scale factor
|
|
|
|
|
|
const calculatedScale = maxDimension > 0 ? targetSize / maxDimension : 0.2;
|
|
|
|
|
|
|
|
|
|
|
|
// Clamp scale to reasonable range
|
|
|
|
|
|
const finalScale = Math.max(0.1, Math.min(2.0, calculatedScale));
|
|
|
|
|
|
|
|
|
|
|
|
return finalScale;
|
|
|
|
|
|
}, [scene, body.name]);
|
2025-11-27 05:16:19 +00:00
|
|
|
|
|
2025-11-30 02:43:47 +00:00
|
|
|
|
// 4. Hook: Configured Scene
|
2025-11-27 05:16:19 +00:00
|
|
|
|
const configuredScene = useMemo(() => {
|
2025-11-30 02:43:47 +00:00
|
|
|
|
if (!scene) return null;
|
|
|
|
|
|
|
2025-11-27 05:16:19 +00:00
|
|
|
|
const clonedScene = scene.clone();
|
|
|
|
|
|
clonedScene.traverse((child: any) => {
|
|
|
|
|
|
if (child.isMesh) {
|
|
|
|
|
|
child.renderOrder = 10000;
|
|
|
|
|
|
if (child.material) {
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2025-11-30 02:43:47 +00:00
|
|
|
|
// 5. Hook: Animation
|
2025-11-27 05:16:19 +00:00
|
|
|
|
useFrame((_, delta) => {
|
|
|
|
|
|
if (groupRef.current) {
|
|
|
|
|
|
groupRef.current.rotation.y += delta * 0.2;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-30 02:43:47 +00:00
|
|
|
|
// Render Logic
|
|
|
|
|
|
if (!scene || !configuredScene) return null;
|
|
|
|
|
|
|
2025-11-27 05:16:19 +00:00
|
|
|
|
// Calculate ACTUAL distance from Sun (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 probe has one
|
|
|
|
|
|
const offsetDesc = renderPosition.hasOffset ? getOffsetDescription(body, allBodies) : null;
|
|
|
|
|
|
|
2025-11-27 05:16:19 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]} ref={groupRef}>
|
|
|
|
|
|
<primitive
|
|
|
|
|
|
object={configuredScene}
|
2025-11-29 15:10:00 +00:00
|
|
|
|
scale={optimalScale}
|
2025-11-27 05:16:19 +00:00
|
|
|
|
/>
|
2025-11-30 02:43:47 +00:00
|
|
|
|
|
|
|
|
|
|
{/* Name label */}
|
2025-11-27 05:16:19 +00:00
|
|
|
|
<Html
|
2025-11-29 15:10:00 +00:00
|
|
|
|
position={[0, optimalScale * 2, 0]}
|
2025-11-27 05:16:19 +00:00
|
|
|
|
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-29 15:10:00 +00:00
|
|
|
|
{offsetDesc && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<br />
|
|
|
|
|
|
<span style={{ fontSize: '10px', color: '#ffaa00', opacity: 0.9 }}>
|
|
|
|
|
|
{offsetDesc}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
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
|
2025-11-29 15:10:00 +00:00
|
|
|
|
function ProbeFallback({ body, allBodies }: { body: CelestialBody; allBodies: CelestialBody[] }) {
|
2025-11-27 05:16:19 +00:00
|
|
|
|
const position = body.positions[0];
|
|
|
|
|
|
|
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]);
|
2025-11-27 05:16:19 +00:00
|
|
|
|
|
2025-11-29 15:10:00 +00:00
|
|
|
|
const scaledPos = { x: renderPosition.x, y: renderPosition.y, z: renderPosition.z };
|
2025-11-27 05:16:19 +00:00
|
|
|
|
|
|
|
|
|
|
// Calculate ACTUAL distance from Sun (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 probe has one
|
|
|
|
|
|
const offsetDesc = renderPosition.hasOffset ? getOffsetDescription(body, allBodies) : null;
|
|
|
|
|
|
|
2025-11-27 05:16:19 +00:00
|
|
|
|
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-29 15:10:00 +00:00
|
|
|
|
{offsetDesc && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<br />
|
|
|
|
|
|
<span style={{ fontSize: '10px', color: '#ffaa00', opacity: 0.9 }}>
|
|
|
|
|
|
{offsetDesc}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2025-11-27 05:16:19 +00:00
|
|
|
|
<br />
|
|
|
|
|
|
<span style={{ fontSize: '10px', opacity: 0.8 }}>
|
|
|
|
|
|
{distance.toFixed(2)} AU
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</Html>
|
|
|
|
|
|
</group>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-29 15:10:00 +00:00
|
|
|
|
export function Probe({ body, allBodies }: ProbeProps) {
|
2025-11-27 05:16:19 +00:00
|
|
|
|
const position = body.positions[0];
|
2025-11-29 15:10:00 +00:00
|
|
|
|
const [modelPath, setModelPath] = useState<string | null | undefined>(undefined);
|
|
|
|
|
|
const [loadError, setLoadError] = useState<boolean>(false);
|
2025-11-27 10:14:25 +00:00
|
|
|
|
|
2025-11-29 15:10:00 +00:00
|
|
|
|
// Fetch model from backend API
|
2025-11-27 10:14:25 +00:00
|
|
|
|
useEffect(() => {
|
2025-11-29 15:10:00 +00:00
|
|
|
|
console.log(`[Probe ${body.name}] Fetching resources...`);
|
|
|
|
|
|
setLoadError(false); // Reset error state
|
|
|
|
|
|
|
|
|
|
|
|
fetchBodyResources(body.id, 'model')
|
|
|
|
|
|
.then((response) => {
|
|
|
|
|
|
console.log(`[Probe ${body.name}] Resources response:`, response);
|
|
|
|
|
|
if (response.resources.length > 0) {
|
|
|
|
|
|
// Get the first model resource
|
|
|
|
|
|
const modelResource = response.resources[0];
|
|
|
|
|
|
// Construct full URL from file_path
|
|
|
|
|
|
const protocol = window.location.protocol;
|
|
|
|
|
|
const hostname = window.location.hostname;
|
|
|
|
|
|
const port = import.meta.env.VITE_API_BASE_URL ? '' : ':8000';
|
|
|
|
|
|
const fullPath = `${protocol}//${hostname}${port}/upload/${modelResource.file_path}`;
|
|
|
|
|
|
console.log(`[Probe ${body.name}] Model path:`, fullPath);
|
|
|
|
|
|
|
|
|
|
|
|
// Preload the model before setting the path
|
|
|
|
|
|
useGLTF.preload(fullPath);
|
|
|
|
|
|
console.log(`[Probe ${body.name}] Model preloaded`);
|
2025-11-27 05:16:19 +00:00
|
|
|
|
|
2025-11-29 15:10:00 +00:00
|
|
|
|
setModelPath(fullPath);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log(`[Probe ${body.name}] No resources found, using fallback`);
|
|
|
|
|
|
setModelPath(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
|
console.error(`[Probe ${body.name}] Failed to load model:`, err);
|
|
|
|
|
|
setLoadError(true);
|
|
|
|
|
|
setModelPath(null);
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [body.id, body.name]);
|
2025-11-27 05:16:19 +00:00
|
|
|
|
|
2025-11-29 15:10:00 +00:00
|
|
|
|
if (!position) {
|
|
|
|
|
|
console.log(`[Probe ${body.name}] No position data`);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
2025-11-27 05:16:19 +00:00
|
|
|
|
|
2025-11-29 15:10:00 +00:00
|
|
|
|
if (modelPath === undefined) {
|
|
|
|
|
|
console.log(`[Probe ${body.name}] Waiting for model path...`);
|
|
|
|
|
|
return null; // Wait for model to load
|
2025-11-27 05:16:19 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-29 15:10:00 +00:00
|
|
|
|
console.log(`[Probe ${body.name}] Rendering with modelPath:`, modelPath, 'loadError:', loadError);
|
2025-11-27 05:16:19 +00:00
|
|
|
|
|
2025-11-29 15:10:00 +00:00
|
|
|
|
// Use model if available and no load error, otherwise use fallback
|
|
|
|
|
|
if (modelPath && !loadError) {
|
|
|
|
|
|
return <ProbeModel body={body} modelPath={modelPath} allBodies={allBodies} onError={() => {
|
|
|
|
|
|
console.error(`[Probe ${body.name}] ProbeModel rendering failed, switching to fallback`);
|
|
|
|
|
|
setLoadError(true);
|
|
|
|
|
|
}} />;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return <ProbeFallback body={body} allBodies={allBodies} />;
|
|
|
|
|
|
}
|