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(null); const lightRef = useRef(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 ( ); } /** 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 ( {ring.map(([x, z], i) => ( ))} ); } /** Grid-fin / stabiliser fins around the base of the first stage. */ function Fins({ radius, height }: { radius: number; height: number }) { return ( {[0, 1, 2, 3].map((i) => ( ))} ); } 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 ( {/* Livery band near the top of the stage */} ); } /** 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 ( {/* Interstage (dark) */} {/* Second-stage engine bell tucked under the interstage */} {/* Second-stage body */} {/* Payload fairing / nose cone */} ); } function PayloadSatellite({ radius, panelProgress }: { radius: number; panelProgress: number }) { const satelliteRef = useRef(null); const easedProgress = 1 - Math.pow(1 - panelProgress, 3); useFrame((_, delta) => { if (satelliteRef.current) satelliteRef.current.rotation.y += delta * 0.18; }); return ( {[-1, 1].map((direction) => ( ))} ); } 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(null); const upperStackRef = useRef(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 ( {/* Upper stack: active through insertion, then discarded after deployment. */} {/* Stage-2 plume: fires from the second-stage bell (above interstage) */} {deployAge !== null && ( )} {/* First stage: attached below the stack, or detached and falling */} {/* Stage-1 plume: fires from the engine cluster at the base (y≈0) */} ); }