Files
rocketry/src/components/rocket/RocketModel3D.jsx
2026-03-04 19:40:46 +00:00

291 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useRef, useEffect } from 'react'
import { Canvas, useFrame, useThree } from '@react-three/fiber'
import { OrbitControls } from '@react-three/drei'
import * as THREE from 'three'
// ── Single body section ────────────────────────────────────────────────
// Renders a CylinderGeometry segment centred at yCenter on the world Y axis.
// rTop = radius at +Y cap, rBot = radius at Y cap.
function Section({ yCenter, yLen, rTop, rBot, color, opacity = 1 }) {
const geo = useMemo(
() => new THREE.CylinderGeometry(
Math.max(rTop, 0.0001),
Math.max(rBot, 0.0001),
yLen, 48, 1, false,
),
[rTop, rBot, yLen],
)
return (
<mesh geometry={geo} position={[0, yCenter, 0]}>
<meshStandardMaterial
color={color}
metalness={0.45}
roughness={0.5}
side={THREE.DoubleSide}
transparent={opacity < 1}
opacity={opacity}
depthWrite={opacity >= 1}
/>
</mesh>
)
}
// ── Domed tank section ─────────────────────────────────────────────────
// Renders a cylindrical tank with hemispherical domes on both ends.
// yCenter = center of the entire tank, L_cyl = cylindrical body length, R = radius
function TankSection({ yCenter, L_cyl, R, color, opacity = 1 }) {
const cylGeo = useMemo(
() => new THREE.CylinderGeometry(R, R, L_cyl, 48, 1, false),
[R, L_cyl],
)
// Top dome: hemisphere pointing upward (positive Y)
const topDomeGeo = useMemo(
() => new THREE.SphereGeometry(R, 32, 16, 0, Math.PI * 2, 0, Math.PI / 2),
[R],
)
// Bottom dome: hemisphere pointing downward (negative Y)
const botDomeGeo = useMemo(
() => new THREE.SphereGeometry(R, 32, 16, 0, Math.PI * 2, Math.PI / 2, Math.PI / 2),
[R],
)
const material = (
<meshStandardMaterial
color={color}
metalness={0.45}
roughness={0.5}
side={THREE.DoubleSide}
transparent={opacity < 1}
opacity={opacity}
depthWrite={opacity >= 1}
/>
)
return (
<>
{/* Cylindrical body */}
<mesh geometry={cylGeo} position={[0, yCenter, 0]}>
{material}
</mesh>
{/* Top dome */}
<mesh geometry={topDomeGeo} position={[0, yCenter + L_cyl / 2 + R / 2, 0]}>
{material}
</mesh>
{/* Bottom dome */}
<mesh geometry={botDomeGeo} position={[0, yCenter - L_cyl / 2 - R / 2, 0]}>
{material}
</mesh>
</>
)
}
// ── Nose cone with profile (LatheGeometry) ────────────────────────────────
// Uses profile points {x, r} to generate a smooth surface of revolution.
// x is mapped to height (y-axis), r is the radius at each point.
// Height is inverted so tip (r=0) ends up at the top of the rocket.
function NoseCone({ yCenter, profilePoints, color, opacity = 1 }) {
const geo = useMemo(() => {
if (!profilePoints || profilePoints.length < 2) return null
const L = profilePoints[profilePoints.length - 1].x
// Map points so height = L - x (inverted), so tip (x=0) becomes height L
// This makes tip point upward when positioned in the rocket
const curvePoints = profilePoints.map(p => new THREE.Vector2(p.r, L - p.x))
return new THREE.LatheGeometry(curvePoints, 32)
}, [profilePoints])
if (!geo) return null
const L = profilePoints[profilePoints.length - 1].x
return (
<mesh geometry={geo} position={[0, yCenter - L / 2, 0]}>
<meshStandardMaterial
color={color}
metalness={0.45}
roughness={0.5}
side={THREE.DoubleSide}
transparent={opacity < 1}
opacity={opacity}
depthWrite={opacity >= 1}
/>
</mesh>
)
}
// ── Rocket composed of stacked vertical sections ───────────────────────
// +Y = nose tip (up) Y = nozzle exit (down / toward ground)
function RocketShape({ geometry: geo }) {
const groupRef = useRef()
// Slow spin around vertical axis
useFrame((_, delta) => {
if (groupRef.current) groupRef.current.rotation.y += delta * 0.18
})
if (!geo) return null
const {
outerRadius: R,
L_nose, L_payload, L_tank, L_tank_fuel, L_tank_ox, L_engine,
totalLength, r_inner, arrangement, innerPropellant, noseProfilePoints,
} = geo
const mid = totalLength / 2
const tankY0 = L_nose + L_payload
const engineY0 = tankY0 + L_tank
// Map rocket-space distance from nose tip → world Y.
// Flips so nose = +Y (top) and engine exit = Y (bottom).
const yc = (y0, h) => mid - y0 - h / 2
return (
<group ref={groupRef}>
{/* Nose cone — profile-based shape */}
{noseProfilePoints && noseProfilePoints.length > 0 ? (
<NoseCone
yCenter={yc(0, L_nose)}
profilePoints={noseProfilePoints}
color="#94a3b8"
/>
) : (
<Section
yCenter={yc(0, L_nose)} yLen={L_nose}
rTop={0.001} rBot={R}
color="#94a3b8"
/>
)}
{/* Payload bay */}
{L_payload > 0 && (
<Section
yCenter={yc(L_nose, L_payload)} yLen={L_payload}
rTop={R} rBot={R}
color="#6366f1"
/>
)}
{/* Tanks — tandem: oxidizer above fuel (with domes) */}
{arrangement === 'tandem' && L_tank_ox_cyl > 0 && (
<TankSection
yCenter={yc(tankY0, L_tank_ox)} L_cyl={L_tank_ox_cyl}
R={R}
color="#06b6d4"
/>
)}
{arrangement === 'tandem' && L_tank_fuel_cyl > 0 && (
<TankSection
yCenter={yc(tankY0 + L_tank_ox, L_tank_fuel)} L_cyl={L_tank_fuel_cyl}
R={R}
color="#f59e0b"
/>
)}
{/* Tanks — coaxial: semi-transparent outer + solid inner */}
{arrangement === 'coaxial' && L_tank > 0 && (
<>
<Section
yCenter={yc(tankY0, L_tank)} yLen={L_tank}
rTop={R} rBot={R}
color={innerPropellant === 'fuel' ? '#06b6d4' : '#f59e0b'}
opacity={0.55}
/>
{r_inner > 0 && (
<Section
yCenter={yc(tankY0, L_tank)} yLen={L_tank}
rTop={r_inner} rBot={r_inner}
color={innerPropellant === 'fuel' ? '#f59e0b' : '#06b6d4'}
/>
)}
</>
)}
{/* Engine — converging (chamber) section: body width → throat */}
{L_engine > 0 && geo.nozzleThroatRadius > 0 && geo.chamberLength > 0 && (
<Section
yCenter={yc(engineY0, geo.chamberLength)} yLen={geo.chamberLength}
rTop={R} rBot={geo.nozzleThroatRadius}
color="#3b82f6"
/>
)}
{/* Engine — diverging (nozzle bell) section: throat → exit (always flares downward) */}
{L_engine > 0 && geo.nozzleThroatRadius > 0 && geo.nozzleLength > 0 && (
<Section
yCenter={yc(engineY0 + geo.chamberLength, geo.nozzleLength)} yLen={geo.nozzleLength}
rTop={geo.nozzleThroatRadius} rBot={geo.nozzleExitRadius ?? R * 1.3}
color="#3b82f6"
/>
)}
{/* Engine — fallback when detailed geometry not available */}
{L_engine > 0 && !(geo.nozzleThroatRadius > 0) && (
<Section
yCenter={yc(engineY0, L_engine)} yLen={L_engine}
rTop={R} rBot={R * 1.3}
color="#3b82f6"
/>
)}
</group>
)
}
// ── Camera auto-fit ────────────────────────────────────────────────────
function CameraRig({ geometry: geo }) {
const { camera, controls } = useThree()
useEffect(() => {
if (!geo) return
const span = Math.max(geo.totalLength, geo.outerRadius * 2)
const dist = span * 2.2
camera.position.set(dist * 0.7, dist * 0.25, dist * 0.7)
camera.near = dist * 0.001
camera.far = dist * 10
camera.updateProjectionMatrix()
if (controls) {
controls.target.set(0, 0, 0)
controls.update()
} else {
camera.lookAt(0, 0, 0)
}
}, [geo, camera, controls])
return null
}
function Scene({ geometry }) {
return (
<>
<ambientLight intensity={0.6} />
<directionalLight position={[5, 8, 5]} intensity={1.4} />
<directionalLight position={[-4, -4, -4]} intensity={0.3} />
<RocketShape geometry={geometry} />
<OrbitControls makeDefault enablePan={false} />
<CameraRig geometry={geometry} />
</>
)
}
export default function RocketModel3D({ geometry }) {
return (
<div className="relative w-full h-full">
{!geometry && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-center pointer-events-none z-10">
<div className="text-5xl mb-4 opacity-20">🚀</div>
<p className="text-slate-500 text-sm leading-relaxed">
Import an engine and enter an outer diameter<br />to see the 3D rocket
</p>
</div>
)}
<Canvas
camera={{ position: [0.5, 0.3, 0.8], fov: 45 }}
style={{ background: 'transparent' }}
>
<Scene geometry={geometry} />
</Canvas>
</div>
)
}