291 lines
9.2 KiB
JavaScript
291 lines
9.2 KiB
JavaScript
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>
|
||
)
|
||
}
|