cosmo/frontend/src/components/Probe.tsx

295 lines
9.6 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];
// 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 (
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]} ref={groupRef}>
<primitive
object={configuredScene}
scale={optimalScale}
/>
{/* Removed the semi-transparent sphere to avoid rendering conflicts */}
{/* Name label - position based on model scale */}
<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} />;
}