/** * Probe component - renders space probes with 3D models */ import { useRef, useMemo, useState, useEffect } from 'react'; import { Group } from 'three'; import * as THREE from 'three'; import { useGLTF, Html } from '@react-three/drei'; import { useFrame } from '@react-three/fiber'; import type { CelestialBody } from '../types'; import { calculateRenderPosition, getOffsetDescription } from '../utils/renderPosition'; import { fetchBodyResources } from '../utils/api'; interface ProbeProps { body: CelestialBody; allBodies: CelestialBody[]; } // Separate component for each probe type to properly use hooks function ProbeModel({ body, modelPath, allBodies, onError }: { body: CelestialBody; modelPath: string; allBodies: CelestialBody[]; onError: () => void; }) { const groupRef = useRef(null); const position = body.positions[0]; // 1. Hook: Render Position 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 }; // 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 const optimalScale = useMemo(() => { if (!scene) return 1; // 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]); // 4. Hook: Configured Scene const configuredScene = useMemo(() => { if (!scene) return null; 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]); // 5. Hook: Animation useFrame((_, delta) => { if (groupRef.current) { groupRef.current.rotation.y += delta * 0.2; } }); // Render Logic if (!scene || !configuredScene) return null; // Calculate ACTUAL distance from Sun (not scaled) const distance = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2); // Get offset description if this probe has one const offsetDesc = renderPosition.hasOffset ? getOffsetDescription(body, allBodies) : null; return ( {/* Name label */} 🛰️ {body.name_zh || body.name} {offsetDesc && ( <>
{offsetDesc} )}
{distance.toFixed(2)} AU
); } // Fallback component when model is not available function ProbeFallback({ body, allBodies }: { body: CelestialBody; allBodies: CelestialBody[] }) { const position = body.positions[0]; // 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 }; // Calculate ACTUAL distance from Sun (not scaled) const distance = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2); // Get offset description if this probe has one const offsetDesc = renderPosition.hasOffset ? getOffsetDescription(body, allBodies) : null; return ( {/* Name label */} 🛰️ {body.name_zh || body.name} {offsetDesc && ( <>
{offsetDesc} )}
{distance.toFixed(2)} AU
); } export function Probe({ body, allBodies }: ProbeProps) { const position = body.positions[0]; const [modelPath, setModelPath] = useState(undefined); const [loadError, setLoadError] = useState(false); // Fetch model from backend API useEffect(() => { 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`); 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]); if (!position) { console.log(`[Probe ${body.name}] No position data`); return null; } if (modelPath === undefined) { console.log(`[Probe ${body.name}] Waiting for model path...`); return null; // Wait for model to load } console.log(`[Probe ${body.name}] Rendering with modelPath:`, modelPath, 'loadError:', loadError); // Use model if available and no load error, otherwise use fallback if (modelPath && !loadError) { return { console.error(`[Probe ${body.name}] ProbeModel rendering failed, switching to fallback`); setLoadError(true); }} />; } return ; }