cosmo/frontend/src/features/rocket-simulator/RocketModel.tsx

321 lines
12 KiB
TypeScript
Raw Normal View History

2026-07-21 12:09:51 +00:00
import { useMemo, useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import type { RocketConfig, SimulationState } from './types';
import { engineState, separationAge, separationTransform, stageGeometry } from './sceneMath';
const DARK = '#20262d';
const METAL = '#8b96a1';
/**
* Animated exhaust plume. The whole plume is anchored at the engine bell (local
* y = 0) and grows DOWNWARD; we scale an anchored group (not a center-origin
* mesh) so the flame root always stays glued to the nozzle regardless of
* throttle/flicker. `nozzleY` is where the engine bell sits in the parent.
*/
function EnginePlume({
radius,
length,
throttle,
active,
running,
nozzleY = 0,
}: {
radius: number;
length: number;
throttle: number;
active: boolean;
running: boolean;
nozzleY?: number;
}) {
const anchorRef = useRef<THREE.Group>(null);
const lightRef = useRef<THREE.PointLight>(null);
useFrame(({ clock }) => {
if (!anchorRef.current || !running) return;
const flicker = 0.85 + Math.sin(clock.elapsedTime * 45) * 0.12 + Math.random() * 0.06;
const stretch = flicker * (0.5 + throttle * 0.7);
// Only Y is animated; the group origin stays at the nozzle so the flame
// never detaches from the tail.
anchorRef.current.scale.set(0.75 + throttle * 0.35, stretch, 0.75 + throttle * 0.35);
if (lightRef.current) lightRef.current.intensity = 5 + flicker * 3;
});
if (!active) return null;
// Cones are built with their tip up and base down, positioned so the base
// (top of cone) sits exactly at the nozzle and the tip points away.
return (
<group ref={anchorRef} position={[0, nozzleY, 0]}>
<mesh position={[0, -length * 0.9, 0]} rotation={[Math.PI, 0, 0]}>
<coneGeometry args={[radius * 2.1, length * 1.8, 20, 1, true]} />
<meshBasicMaterial color="#ff7a2a" transparent opacity={0.32} blending={THREE.AdditiveBlending} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
<mesh position={[0, -length * 0.5, 0]} rotation={[Math.PI, 0, 0]}>
<coneGeometry args={[radius * 1.15, length, 20, 1, true]} />
<meshBasicMaterial color="#fff2c2" transparent opacity={0.9} blending={THREE.AdditiveBlending} depthWrite={false} side={THREE.DoubleSide} />
</mesh>
<pointLight ref={lightRef} color="#ff9a3c" intensity={6} distance={radius * 30} decay={2} position={[0, -length * 0.4, 0]} />
</group>
);
}
/** Nozzle cluster drawn as a dark boat-tail plus a few nozzle cones. */
function EngineCluster({ radius, count }: { radius: number; count: number }) {
const nozzles = Math.min(count, 9);
const ring = useMemo(() => {
if (nozzles <= 1) return [[0, 0]] as Array<[number, number]>;
const points: Array<[number, number]> = [[0, 0]];
const outer = nozzles - 1;
for (let i = 0; i < outer; i += 1) {
const angle = (i / outer) * Math.PI * 2;
points.push([Math.cos(angle) * radius * 0.5, Math.sin(angle) * radius * 0.5]);
}
return points;
}, [nozzles, radius]);
return (
<group>
<mesh position={[0, radius * 0.4, 0]}>
<cylinderGeometry args={[radius * 0.82, radius, radius * 0.8, 24]} />
<meshStandardMaterial color={DARK} metalness={0.6} roughness={0.5} />
</mesh>
{ring.map(([x, z], i) => (
<mesh key={i} position={[x, -radius * 0.2, z]} rotation={[Math.PI, 0, 0]}>
<coneGeometry args={[radius * 0.16, radius * 0.55, 12, 1, true]} />
<meshStandardMaterial color="#3a4149" metalness={0.7} roughness={0.35} side={THREE.DoubleSide} />
</mesh>
))}
</group>
);
}
/** Grid-fin / stabiliser fins around the base of the first stage. */
function Fins({ radius, height }: { radius: number; height: number }) {
return (
<group>
{[0, 1, 2, 3].map((i) => (
<mesh key={i} position={[0, height * 0.5, 0]} rotation={[0, (i / 4) * Math.PI * 2, 0]}>
<boxGeometry args={[radius * 0.12, height, radius * 2.4]} />
<meshStandardMaterial color={DARK} metalness={0.5} roughness={0.6} />
</mesh>
))}
</group>
);
}
interface StageProps {
radius: number;
height: number;
color: string;
}
/** First-stage airframe with fins and engine cluster; base sits at local y=0. */
function FirstStageBody({ radius, height, color, engineCount }: StageProps & { engineCount: number }) {
return (
<group>
<mesh position={[0, height * 0.5, 0]}>
<cylinderGeometry args={[radius, radius, height, 32]} />
<meshStandardMaterial color={color} metalness={0.35} roughness={0.45} />
</mesh>
{/* Livery band near the top of the stage */}
<mesh position={[0, height * 0.9, 0]}>
<cylinderGeometry args={[radius * 1.005, radius * 1.005, height * 0.05, 32]} />
<meshStandardMaterial color={DARK} metalness={0.4} roughness={0.5} />
</mesh>
<group position={[0, height * 0.16, 0]}>
<Fins radius={radius} height={height * 0.22} />
</group>
<EngineCluster radius={radius} count={engineCount} />
</group>
);
}
/** Interstage + second stage + payload fairing/nose; base sits at local y=0. */
function UpperStack({
radius,
interstageHeight,
stage2Height,
noseHeight,
color,
engineCount,
}: {
radius: number;
interstageHeight: number;
stage2Height: number;
noseHeight: number;
color: string;
engineCount: number;
}) {
const stage2Base = interstageHeight;
const noseBase = interstageHeight + stage2Height;
return (
<group>
{/* Interstage (dark) */}
<mesh position={[0, interstageHeight * 0.5, 0]}>
<cylinderGeometry args={[radius * 0.98, radius, interstageHeight, 32]} />
<meshStandardMaterial color={DARK} metalness={0.5} roughness={0.5} />
</mesh>
{/* Second-stage engine bell tucked under the interstage */}
<group position={[0, stage2Base, 0]}>
<EngineCluster radius={radius * 0.7} count={Math.min(engineCount, 3)} />
</group>
{/* Second-stage body */}
<mesh position={[0, stage2Base + stage2Height * 0.5, 0]}>
<cylinderGeometry args={[radius * 0.96, radius * 0.98, stage2Height, 32]} />
<meshStandardMaterial color={color} metalness={0.35} roughness={0.45} />
</mesh>
{/* Payload fairing / nose cone */}
<mesh position={[0, noseBase + noseHeight * 0.5, 0]}>
<coneGeometry args={[radius * 0.96, noseHeight, 32]} />
<meshStandardMaterial color={METAL} metalness={0.4} roughness={0.4} />
</mesh>
</group>
);
}
function PayloadSatellite({ radius, panelProgress }: { radius: number; panelProgress: number }) {
const satelliteRef = useRef<THREE.Group>(null);
const easedProgress = 1 - Math.pow(1 - panelProgress, 3);
useFrame((_, delta) => {
if (satelliteRef.current) satelliteRef.current.rotation.y += delta * 0.18;
});
return (
<group ref={satelliteRef}>
<mesh>
<boxGeometry args={[radius * 1.35, radius * 1.15, radius * 1.2]} />
<meshStandardMaterial color="#d2a84f" metalness={0.65} roughness={0.38} />
</mesh>
<mesh position={[0, radius * 0.76, 0]}>
<cylinderGeometry args={[radius * 0.28, radius * 0.42, radius * 0.42, 20]} />
<meshStandardMaterial color="#d9ddda" metalness={0.65} roughness={0.28} />
</mesh>
<mesh position={[0, radius * 1.08, 0]} rotation={[Math.PI / 2, 0, 0]}>
<sphereGeometry args={[radius * 0.54, 24, 12, 0, Math.PI * 2, 0, Math.PI / 2]} />
<meshStandardMaterial color="#eff2ef" metalness={0.35} roughness={0.4} side={THREE.BackSide} />
</mesh>
{[-1, 1].map((direction) => (
<group key={direction} position={[direction * radius * 0.78, 0, 0]}>
<mesh
position={[direction * radius * 1.55 * easedProgress, 0, 0]}
scale={[Math.max(0.04, easedProgress), 1, 1]}
>
<boxGeometry args={[radius * 3.1, radius * 0.72, radius * 0.08]} />
<meshStandardMaterial color="#24507a" metalness={0.25} roughness={0.55} />
</mesh>
<mesh position={[direction * radius * 0.08, 0, 0]}>
<boxGeometry args={[radius * 0.18, radius * 0.18, radius * 0.16]} />
<meshStandardMaterial color={DARK} metalness={0.7} roughness={0.35} />
</mesh>
</group>
))}
</group>
);
}
interface RocketModelProps {
rocket: RocketConfig;
state: SimulationState;
}
/**
* Full launch vehicle. Each discarded stage drifts away from the active stack;
* after orbital insertion, the payload separates and deploys its solar arrays.
*/
export function RocketModel({ rocket, state }: RocketModelProps) {
const geo = useMemo(() => stageGeometry(rocket), [rocket]);
const engines = engineState(state);
const age = separationAge(state);
const sep = separationTransform(age);
const deployEvent = state.events.find((event) => event.id === 'deploy');
const deployAge = deployEvent ? Math.max(0, state.time - deployEvent.time) : null;
const deployProgress = deployAge === null ? 0 : Math.min(1, deployAge / 2.4);
const firstStageRef = useRef<THREE.Group>(null);
const upperStackRef = useRef<THREE.Group>(null);
// The upper stack stays anchored until payload deployment. The first stage
// sits directly below it (base at local y=0) until stage separation.
const upperBaseY = geo.stage1Height;
const payloadY = upperBaseY + geo.interstageHeight + geo.stage2Height + geo.noseHeight * 0.58;
useFrame(() => {
if (firstStageRef.current) {
if (sep) {
firstStageRef.current.position.set(sep.drift, -sep.drop, 0);
firstStageRef.current.rotation.set(sep.tumble * 0.6, sep.tumble * 0.3, sep.tumble);
firstStageRef.current.visible = sep.opacity > 0.02;
} else {
firstStageRef.current.position.set(0, 0, 0);
firstStageRef.current.rotation.set(0, 0, 0);
firstStageRef.current.visible = true;
}
}
if (upperStackRef.current) {
if (deployAge === null) {
upperStackRef.current.position.set(0, upperBaseY, 0);
upperStackRef.current.rotation.set(0, 0, 0);
upperStackRef.current.visible = true;
} else {
upperStackRef.current.position.set(-deployAge * 0.72, upperBaseY - deployAge * 0.82, deployAge * 0.18);
upperStackRef.current.rotation.set(deployAge * 0.16, deployAge * 0.08, -deployAge * 0.3);
upperStackRef.current.visible = deployAge < 5.2;
}
}
});
return (
<group>
{/* Upper stack: active through insertion, then discarded after deployment. */}
<group ref={upperStackRef} position={[0, upperBaseY, 0]}>
<UpperStack
radius={geo.radius}
interstageHeight={geo.interstageHeight}
stage2Height={geo.stage2Height}
noseHeight={geo.noseHeight}
color={rocket.color}
engineCount={rocket.stage_2.engine_count}
/>
{/* Stage-2 plume: fires from the second-stage bell (above interstage) */}
<group position={[0, geo.interstageHeight, 0]}>
<EnginePlume
radius={geo.radius * 0.7}
length={geo.radius * 7}
nozzleY={-geo.radius * 0.7 * 0.4}
throttle={state.throttle}
active={engines.stage2}
running={state.isRunning}
/>
</group>
</group>
{deployAge !== null && (
<group position={[deployProgress * 1.8, payloadY + deployProgress * 2.6, 0]} scale={1.25}>
<PayloadSatellite radius={geo.radius} panelProgress={deployProgress} />
</group>
)}
{/* First stage: attached below the stack, or detached and falling */}
<group ref={firstStageRef}>
<FirstStageBody
radius={geo.radius}
height={geo.stage1Height}
color={rocket.color}
engineCount={rocket.stage_1.engine_count}
/>
{/* Stage-1 plume: fires from the engine cluster at the base (y≈0) */}
<EnginePlume
radius={geo.radius}
length={geo.radius * 10}
nozzleY={-geo.radius * 0.4}
throttle={state.throttle}
active={engines.stage1}
running={state.isRunning}
/>
</group>
</group>
);
}