/** * 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]; // 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 }; // Load 3D model - must be at top level // Add error handling and logging let scene; try { const gltf = useGLTF(modelPath); scene = gltf.scene; console.log(`[ProbeModel ${body.name}] GLTF loaded successfully:`, { children: scene.children.length, modelPath }); } catch (error) { console.error(`[ProbeModel ${body.name}] Error loading GLTF:`, error); // Call error callback and return null to trigger fallback onError(); return null; } if (!scene || !scene.children || scene.children.length === 0) { console.error(`[ProbeModel ${body.name}] GLTF scene is empty or invalid`); onError(); return null; } // Calculate optimal scale based on model bounding box const optimalScale = useMemo(() => { // 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 // If model is very small, scale it up; if very large, scale it down const calculatedScale = maxDimension > 0 ? targetSize / maxDimension : 0.2; // Clamp scale to reasonable range const finalScale = Math.max(0.1, Math.min(2.0, calculatedScale)); console.log(`[ProbeModel ${body.name}] Model dimensions:`, { x: size.x.toFixed(3), y: size.y.toFixed(3), z: size.z.toFixed(3), maxDimension: maxDimension.toFixed(3), calculatedScale: calculatedScale.toFixed(3), finalScale: finalScale.toFixed(3) }); return finalScale; }, [scene, body.name]); // 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); // Get offset description if this probe has one const offsetDesc = renderPosition.hasOffset ? getOffsetDescription(body, allBodies) : null; return ( {/* Removed the semi-transparent sphere to avoid rendering conflicts */} {/* Name label - position based on model scale */} 🛰️ {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 ; }