cosmo/frontend/src/components/BodyViewer.tsx

521 lines
17 KiB
TypeScript
Raw Normal View History

import { useRef, useMemo, useState, useEffect, Suspense } from 'react';
2026-01-01 18:41:51 +00:00
import { Mesh, Group, DoubleSide } from 'three';
import * as THREE from 'three';
import { useGLTF, useTexture } from '@react-three/drei';
import { useFrame } from '@react-three/fiber';
import type { CelestialBody as CelestialBodyType } from '../types';
import { fetchBodyResources } from '../utils/api';
import { getCelestialSize } from '../config/celestialSizes';
interface BodyViewerProps {
body: CelestialBodyType;
2025-12-06 09:06:39 +00:00
disableGlow?: boolean; // 禁用光晕效果(用于详情视图)
}
2026-01-01 18:41:51 +00:00
// Planetary Rings component - handles texture-based rings
function PlanetaryRings({ texturePath, planetRadius }: { texturePath?: string | null, planetRadius: number }) {
const texture = texturePath ? useTexture(texturePath) : null;
const meshRef = useRef<Mesh>(null);
// Dynamic ring dimensions based on planet size
// Standard Saturn proportions: Inner ~1.1 R, Outer ~2.3 R
const innerRadius = planetRadius * 1.05;
const outerRadius = planetRadius * 2.2;
// Custom geometry with Polar UV mapping for strip textures
const geometry = useMemo(() => {
if (!texturePath) return null;
const geo = new THREE.RingGeometry(innerRadius, outerRadius, 64);
const pos = geo.attributes.position;
const uv = geo.attributes.uv;
// Manually remap UVs to be polar:
// We map normalized radius to BOTH U and V coordinates.
// This allows the texture to work whether it's a horizontal strip OR a vertical strip.
// - If horizontal strip: U varies with radius (correct), V varies (doesn't matter as strip is uniform vertically)
// - If vertical strip: V varies with radius (correct), U varies (doesn't matter as strip is uniform horizontally)
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const y = pos.getY(i);
// Calculate radius from center
const r = Math.sqrt(x * x + y * y);
// Normalize radius: 0 at inner, 1 at outer
// Clamp to ensure floating point errors don't break texture wrapping
const normalizedR = Math.max(0, Math.min(1, (r - innerRadius) / (outerRadius - innerRadius)));
// Map to UV (same value for both axes to support H/V strips)
uv.setXY(i, normalizedR, normalizedR);
}
geo.attributes.uv.needsUpdate = true;
return geo;
}, [texturePath, innerRadius, outerRadius]);
if (texture && geometry) {
return (
<mesh
ref={meshRef}
rotation={[-Math.PI / 2, 0, 0]}
geometry={geometry}
receiveShadow
castShadow
>
<meshStandardMaterial
map={texture}
transparent
opacity={0.9}
side={DoubleSide}
color="#ffffff"
roughness={0.8}
/>
</mesh>
);
}
return null;
}
2025-12-06 09:06:39 +00:00
export function BodyViewer({ body, disableGlow = false }: BodyViewerProps) {
const meshRef = useRef<Mesh>(null);
const [texturePath, setTexturePath] = useState<string | null | undefined>(undefined);
2026-01-01 18:41:51 +00:00
const [ringTexturePath, setRingTexturePath] = useState<string | null | undefined>(undefined);
const [modelPath, setModelPath] = useState<string | null | undefined>(undefined);
const [modelScale, setModelScale] = useState<number>(1.0);
const [loadError, setLoadError] = useState<boolean>(false);
// Determine size and appearance - use larger sizes for detail view with a cap
const appearance = useMemo(() => {
const baseSize = getCelestialSize(body.name, body.type);
// Detail view scaling strategy:
// - Small bodies (< 0.15): scale up 3x for visibility
// - Medium bodies (0.15-0.3): scale up 2x
// - Large bodies (> 0.3): use base size with max cap of 1.2
let finalSize: number;
if (body.type === 'star') {
finalSize = 1.0; // Fixed size for stars
} else if (baseSize < 0.15) {
// Small bodies: scale up for visibility
finalSize = baseSize * 3.0;
} else if (baseSize < 0.3) {
// Medium bodies: moderate scaling
finalSize = baseSize * 2.0;
} else {
// Large bodies: minimal or no scaling with a cap
finalSize = Math.min(baseSize * 1.2, 1.2);
}
if (body.type === 'star') {
2025-12-06 09:06:39 +00:00
// Use database color if available (for interstellar stars), otherwise default to yellow
const starColor = body.starSystemData?.color || '#FDB813';
return { size: finalSize, emissive: starColor, emissiveIntensity: 1.5 };
}
if (body.type === 'comet') {
return { size: finalSize, emissive: '#000000', emissiveIntensity: 0 };
}
if (body.type === 'satellite') {
return { size: finalSize, emissive: '#888888', emissiveIntensity: 0.4 };
}
return { size: finalSize, emissive: '#000000', emissiveIntensity: 0 };
}, [body.name, body.type]);
// Fetch resources (texture or model)
useEffect(() => {
setLoadError(false);
setModelPath(undefined);
setTexturePath(undefined);
2026-01-01 18:41:51 +00:00
setRingTexturePath(undefined);
const loadResources = async () => {
try {
const response = await fetchBodyResources(body.id, body.type === 'probe' ? 'model' : 'texture');
if (response.resources.length > 0) {
2026-01-01 18:41:51 +00:00
if (body.type === 'probe') {
2026-01-01 18:41:51 +00:00
const mainResource = response.resources[0];
const fullPath = `/upload/${mainResource.file_path}`;
useGLTF.preload(fullPath); // Preload GLTF
setModelPath(fullPath);
setModelScale(mainResource.extra_data?.scale || 1.0);
} else {
2026-01-01 18:56:32 +00:00
// Find main texture: layer is surface, base, or empty
2026-01-01 18:41:51 +00:00
const mainTexture = response.resources.find(
2026-01-01 18:56:32 +00:00
(r) => !r.extra_data?.layer || r.extra_data.layer === 'surface' || r.extra_data.layer === 'base'
2026-01-01 18:41:51 +00:00
);
2026-01-01 18:56:32 +00:00
// Find ring texture: strictly by layer meta-data
2026-01-01 18:41:51 +00:00
const ringTexture = response.resources.find(
2026-01-01 18:56:32 +00:00
(r) => r.extra_data?.layer === 'ring'
2026-01-01 18:41:51 +00:00
);
if (mainTexture) {
setTexturePath(`/upload/${mainTexture.file_path}`);
} else {
setTexturePath(null);
}
if (ringTexture) {
setRingTexturePath(`/upload/${ringTexture.file_path}`);
} else {
setRingTexturePath(null);
}
}
} else {
// No resources found
if (body.type === 'probe') setModelPath(null);
2026-01-01 18:41:51 +00:00
else {
setTexturePath(null);
setRingTexturePath(null);
}
}
} catch (err) {
console.error(`Failed to load resource for ${body.name}:`, err);
setLoadError(true);
if (body.type === 'probe') setModelPath(null);
2026-01-01 18:41:51 +00:00
else {
setTexturePath(null);
setRingTexturePath(null);
}
}
};
loadResources();
}, [body.id, body.name, body.type]);
// Handle Probe Model rendering
if (body.type === 'probe' && modelPath !== undefined) {
if (modelPath === null || loadError) {
// Fallback sphere for probes if model fails
return (
<mesh ref={meshRef}>
<sphereGeometry args={[0.15, 32, 32]} />
<meshStandardMaterial color="#ff0000" emissive="#ff0000" emissiveIntensity={0.8} />
</mesh>
);
}
return <ProbeModelViewer modelPath={modelPath} modelScale={modelScale} />;
}
// Handle Celestial Body (Planet, Star, etc.) rendering
if (texturePath !== undefined) {
return (
<PlanetModelViewer
body={body}
size={appearance.size}
emissive={appearance.emissive}
emissiveIntensity={appearance.emissiveIntensity}
texturePath={texturePath}
2026-01-01 18:41:51 +00:00
ringTexturePath={ringTexturePath}
meshRef={meshRef}
2025-12-06 09:06:39 +00:00
disableGlow={disableGlow}
/>
);
}
// Show nothing while loading resources
return null;
}
// Sub-component for Probe models
function ProbeModelViewer({ modelPath, modelScale }: { modelPath: string; modelScale: number }) {
const groupRef = useRef<Group>(null);
const gltf = useGLTF(modelPath);
const scene = gltf.scene;
const optimalScale = useMemo(() => {
if (!scene) return 1;
const box = new THREE.Box3().setFromObject(scene);
const size = new THREE.Vector3();
box.getSize(size);
const maxDimension = Math.max(size.x, size.y, size.z);
const targetSize = 1.2; // Increased from 0.35 to 1.2 for detail view - larger probes
const calculatedScale = maxDimension > 0 ? targetSize / maxDimension : 0.8;
const finalScale = Math.max(0.5, Math.min(3.0, calculatedScale)); // Increased range for larger display
return finalScale * modelScale;
}, [scene, modelScale]);
const configuredScene = useMemo(() => {
if (!scene) return null;
const clonedScene = scene.clone();
clonedScene.traverse((child: any) => {
if (child.isMesh) {
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;
return clonedMat;
});
} else {
child.material = child.material.clone();
child.material.depthTest = true;
child.material.depthWrite = true;
}
}
}
});
return clonedScene;
}, [scene]);
if (!configuredScene) return null;
return (
<Suspense fallback={null}>
<group ref={groupRef}>
<primitive object={configuredScene} scale={optimalScale} />
</group>
</Suspense>
);
}
// Sub-component for Planet models
2026-01-01 18:41:51 +00:00
function PlanetModelViewer({ body, size, emissive, emissiveIntensity, texturePath, ringTexturePath, meshRef, disableGlow = false }: {
body: CelestialBodyType;
size: number;
emissive: string;
emissiveIntensity: number;
texturePath: string | null;
2026-01-01 18:41:51 +00:00
ringTexturePath?: string | null;
meshRef: React.RefObject<Mesh>;
2025-12-06 09:06:39 +00:00
disableGlow?: boolean;
}) {
const texture = texturePath ? useTexture(texturePath) : null;
// Rotation animation
useFrame((_, delta) => {
if (meshRef.current) {
meshRef.current.rotation.y += delta * 0.1;
}
});
// Irregular Comet Nucleus - potato-like shape
function IrregularNucleus({ size, texture }: { size: number; texture: THREE.Texture | null }) {
const nucleusRef = useRef<Mesh>(null);
// Create irregular geometry by deforming a sphere
const geometry = useMemo(() => {
const geo = new THREE.SphereGeometry(size, 64, 64); // Higher resolution for detail view
const positions = geo.attributes.position;
// Deform vertices to create irregular shape
for (let i = 0; i < positions.count; i++) {
const x = positions.getX(i);
const y = positions.getY(i);
const z = positions.getZ(i);
// Create multiple noise frequencies for complex shape
const noise1 = Math.sin(x * 3.5) * Math.cos(y * 2.7) * Math.sin(z * 4.2);
const noise2 = Math.cos(x * 5.1) * Math.sin(y * 4.3) * Math.cos(z * 3.8);
const noise3 = Math.sin(x * 7.2) * Math.sin(y * 6.1) * Math.cos(z * 5.5);
const deformation = 1 + (noise1 * 0.25 + noise2 * 0.15 + noise3 * 0.1);
positions.setXYZ(i, x * deformation, y * deformation, z * deformation);
}
geo.computeVertexNormals();
return geo;
}, [size]);
// Slow rotation
useFrame((_, delta) => {
if (nucleusRef.current) {
nucleusRef.current.rotation.y += delta * 0.05;
nucleusRef.current.rotation.z += delta * 0.02;
}
});
return (
<mesh ref={nucleusRef} geometry={geometry}>
{texture ? (
<meshStandardMaterial
map={texture}
roughness={0.9}
metalness={0.1}
color="#b8b8b8"
/>
) : (
<meshStandardMaterial
color="#6b6b6b"
roughness={0.95}
metalness={0.05}
/>
)}
</mesh>
);
}
// Enhanced Coma (gas/dust cloud) around comet nucleus
function CometComa({ radius }: { radius: number }) {
const positions = useMemo(() => {
const count = 300; // More particles for detail view
const p = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
// Denser near center, spreading outward
const r = radius * (0.8 + Math.pow(Math.random(), 1.5) * 4); // 0.8x to 4.8x radius
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
p[i * 3] = r * Math.sin(phi) * Math.cos(theta);
p[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
p[i * 3 + 2] = r * Math.cos(phi);
}
return p;
}, [radius]);
const pointsRef = useRef<THREE.Points>(null);
// Animate coma
useFrame((state, delta) => {
if (pointsRef.current) {
pointsRef.current.rotation.y += delta * 0.05;
// Pulsing effect
const scale = 1 + Math.sin(state.clock.elapsedTime * 0.5) * 0.1;
pointsRef.current.scale.setScalar(scale);
}
});
return (
<>
{/* Inner bright coma */}
<points ref={pointsRef}>
<bufferGeometry>
<bufferAttribute attach="position" args={[positions, 3]} />
</bufferGeometry>
<pointsMaterial
size={radius * 0.8}
color="#88ccff"
transparent
opacity={0.7}
sizeAttenuation={true}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</points>
{/* Middle glow layer */}
<mesh>
<sphereGeometry args={[radius * 2.5, 32, 32]} />
<meshBasicMaterial
color="#66aadd"
transparent
opacity={0.15}
side={THREE.BackSide}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</mesh>
{/* Outer diffuse glow */}
<mesh>
<sphereGeometry args={[radius * 4.5, 32, 32]} />
<meshBasicMaterial
color="#4488aa"
transparent
opacity={0.12}
side={THREE.BackSide}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</mesh>
</>
);
}
return (
<group>
{/* Use irregular nucleus for comets, regular sphere for others */}
{body.type === 'comet' ? (
<>
<IrregularNucleus size={size} texture={texture} />
<CometComa radius={size} />
</>
) : (
<mesh ref={meshRef} position={[0,0,0]}>
<sphereGeometry args={[size, 64, 64]} /> {/* Higher resolution for detail view */}
{texture ? (
<meshStandardMaterial
map={texture}
emissive={emissive}
emissiveIntensity={emissiveIntensity}
roughness={body.type === 'star' ? 0 : 0.7}
metalness={0.1}
/>
) : (
<meshStandardMaterial
color="#888888"
emissive={emissive}
emissiveIntensity={emissiveIntensity}
roughness={0.7}
metalness={0.1}
/>
)}
</mesh>
)}
2026-01-01 18:41:51 +00:00
{/* Planetary Rings: Render ONLY if texture exists */}
{ringTexturePath && (
<PlanetaryRings texturePath={ringTexturePath} planetRadius={size} />
)}
2025-12-06 09:06:39 +00:00
{/* Sun glow effect - multi-layer scattered light (仅在非禁用光晕模式下显示) */}
{body.type === 'star' && !disableGlow && (
<>
<pointLight intensity={10} distance={400} color="#fff8e7" />
{/* Inner bright corona */}
<mesh>
<sphereGeometry args={[size * 1.3, 32, 32]} />
<meshBasicMaterial
color="#FDB813"
transparent
opacity={0.6}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</mesh>
{/* Middle glow layer */}
<mesh>
<sphereGeometry args={[size * 1.8, 32, 32]} />
<meshBasicMaterial
color="#FDB813"
transparent
opacity={0.3}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</mesh>
{/* Outer diffuse halo */}
<mesh>
<sphereGeometry args={[size * 2.5, 32, 32]} />
<meshBasicMaterial
color="#FFD700"
transparent
opacity={0.15}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</mesh>
{/* Far scattered light */}
<mesh>
<sphereGeometry args={[size * 3.5, 32, 32]} />
<meshBasicMaterial
color="#FFA500"
transparent
opacity={0.08}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</mesh>
</>
)}
</group>
);
}