276 lines
8.8 KiB
TypeScript
276 lines
8.8 KiB
TypeScript
/**
|
||
* 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} />;
|
||
}
|