cosmo/frontend/src/components/Probe.tsx

276 lines
8.8 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-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-29 15:10:00 +00:00
const scaledPos = { x: renderPosition.x, y: renderPosition.y, z: renderPosition.z };
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-30 02:43:47 +00:00
// 4. Hook: Configured Scene
const configuredScene = useMemo(() => {
2025-11-30 02:43:47 +00:00
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]);
2025-11-30 02:43:47 +00:00
// 5. Hook: Animation
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;
// 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}
/>
2025-11-30 02:43:47 +00:00
{/* Name label */}
<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} />;
}