cosmo/frontend/src/components/Probe.tsx

276 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

/**
* 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<Group>(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 (
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]} ref={groupRef}>
<primitive
object={configuredScene}
scale={optimalScale}
/>
{/* Name label */}
<Html
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',
}}
>
🛰 {body.name_zh || body.name}
{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
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 (
<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',
}}
>
🛰 {body.name_zh || body.name}
{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>
);
}
export function Probe({ body, allBodies }: ProbeProps) {
const position = body.positions[0];
const [modelPath, setModelPath] = useState<string | null | undefined>(undefined);
const [loadError, setLoadError] = useState<boolean>(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 <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} />;
}