cosmo/frontend/src/components/Probe.tsx

295 lines
9.6 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';
2025-11-29 15:10:00 +00:00
import * as THREE from 'three';
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';
interface ProbeProps {
body: CelestialBody;
2025-11-29 15:10:00 +00:00
allBodies: CelestialBody[];
}
// 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;
}) {
const groupRef = useRef<Group>(null);
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-29 15:10:00 +00:00
const scaledPos = { x: renderPosition.x, y: renderPosition.y, z: renderPosition.z };
// Load 3D model - must be at top level
2025-11-29 15:10:00 +00:00
// 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);
2025-11-29 15:10:00 +00:00
// Get offset description if this probe has one
const offsetDesc = renderPosition.hasOffset ? getOffsetDescription(body, allBodies) : null;
return (
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]} ref={groupRef}>
<primitive
object={configuredScene}
2025-11-29 15:10:00 +00:00
scale={optimalScale}
/>
{/* Removed the semi-transparent sphere to avoid rendering conflicts */}
2025-11-29 15:10:00 +00:00
{/* Name label - position based on model scale */}
<Html
2025-11-29 15:10:00 +00:00
position={[0, optimalScale * 2, 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}
2025-11-29 15:10:00 +00:00
{offsetDesc && (
<>
<br />
<span style={{ fontSize: '10px', color: '#ffaa00', opacity: 0.9 }}>
{offsetDesc}
</span>
</>
)}
<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[] }) {
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-29 15:10:00 +00:00
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);
2025-11-29 15:10:00 +00:00
// Get offset description if this probe has one
const offsetDesc = renderPosition.hasOffset ? getOffsetDescription(body, allBodies) : null;
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>
</>
)}
<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) {
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-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-29 15:10:00 +00:00
if (!position) {
console.log(`[Probe ${body.name}] No position data`);
return null;
}
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-29 15:10:00 +00:00
console.log(`[Probe ${body.name}] Rendering with modelPath:`, modelPath, 'loadError:', loadError);
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} />;
}