import { useRef, useState, useEffect } from 'react' import { createPortal } from 'react-dom' import { useEngineDesign } from '../hooks/useEngineDesign.js' import { PropellantModal } from '../components/PropellantModal.jsx' import { exportEngineJSON, exportEngineOdt, parseEngineImport, downloadBlob, } from '../engine/engineExportImport.js' import DesignSection from '../components/engine/DesignSection.jsx' import EngineModel3D from '../components/engine/EngineModel3D.jsx' import NozzleDiagram from '../components/engine/NozzleDiagram.jsx' import PerformanceCharts from '../components/engine/PerformanceCharts.jsx' import ErrorBoundary from '../components/ErrorBoundary.jsx' import { formatValue } from '../engine/format.js' import { getUnitsForFamily } from '../engine/units.js' import { ENGINE_FIELD_INFO } from '../engine/engineFieldInfo.js' import { ABLATIVE_MATERIALS, STRUCTURAL_MATERIALS } from '../engine/knowledgebaseData.js' /* ── Info popup ───────────────────────────────────────────────────── */ function InfoPopup({ infoKey, anchorRef, onClose }) { const popupRef = useRef(null) const info = ENGINE_FIELD_INFO[infoKey] const [pos, setPos] = useState({ top: 0, left: 0 }) useEffect(() => { if (anchorRef.current) { const r = anchorRef.current.getBoundingClientRect() setPos({ top: r.bottom + 6, left: Math.min(r.left, window.innerWidth - 230), }) } }, [anchorRef]) useEffect(() => { function onKey(e) { if (e.key === 'Escape') onClose() } function onDown(e) { if (!popupRef.current?.contains(e.target) && !anchorRef.current?.contains(e.target)) { onClose() } } document.addEventListener('keydown', onKey) document.addEventListener('mousedown', onDown) return () => { document.removeEventListener('keydown', onKey) document.removeEventListener('mousedown', onDown) } }, [onClose, anchorRef]) if (!info) return null return createPortal(

{info.name}

{info.description}

{info.higher &&

↑ {info.higher}

} {info.lower &&

↓ {info.lower}

}
, document.body, ) } /* ── Small reusable input components ──────────────────────────────── */ function NumInput({ label, value, onChange, units, step, placeholder, unitFamily, defaultUnitId, infoKey }) { const unitList = unitFamily ? getUnitsForFamily(unitFamily) : null const [selectedUnitId, setSelectedUnitId] = useState( () => defaultUnitId ?? unitList?.[0]?.id ?? null, ) const [popupOpen, setPopupOpen] = useState(false) const infoRef = useRef(null) const selectedUnit = unitList?.find(u => u.id === selectedUnitId) ?? unitList?.[0] ?? null const displayValue = value == null ? '' : selectedUnit ? selectedUnit.fromSI(value) : value function handleChange(str) { if (str === '') { onChange(null); return } const typed = parseFloat(str) if (isNaN(typed)) return onChange(selectedUnit ? selectedUnit.toSI(typed) : typed) } return (
handleChange(e.target.value)} className="flex-1 min-w-0 w-24 px-2 py-1 bg-slate-800 border border-slate-600 rounded text-sm text-slate-100 focus:border-blue-500 focus:outline-none placeholder-slate-600" /> {unitList && unitList.length > 1 ? ( ) : unitList?.[0] ? ( {unitList[0].label} ) : units ? ( {units} ) : null} {infoKey && ( <> {popupOpen && ( setPopupOpen(false)} /> )} )}
) } function SelectInput({ label, value, onChange, options, infoKey }) { const [popupOpen, setPopupOpen] = useState(false) const infoRef = useRef(null) return (
{infoKey && ( <> {popupOpen && ( setPopupOpen(false)} /> )} )}
) } /* ── Result display helpers ───────────────────────────────────────── */ function ResultRow({ label, value, unit, unitFamily, defaultUnitId, infoKey }) { const unitList = unitFamily ? getUnitsForFamily(unitFamily) : null const [selectedUnitId, setSelectedUnitId] = useState( () => defaultUnitId ?? unitList?.[0]?.id ?? null, ) const [popupOpen, setPopupOpen] = useState(false) const infoRef = useRef(null) const selectedUnit = unitList?.find(u => u.id === selectedUnitId) ?? unitList?.[0] ?? null const rawDisplay = value !== null && value !== undefined && isFinite(value) ? (selectedUnit ? selectedUnit.fromSI(value) : value) : null const display = rawDisplay !== null ? formatValue(rawDisplay) : '—' const unitLabel = selectedUnit?.label ?? unit return (
{label} {display} {unitList && unitList.length > 1 ? ( ) : ( unitLabel && {unitLabel} )} {infoKey && ( <> {popupOpen && ( setPopupOpen(false)} /> )} )}
) } function ResultSection({ title, children }) { return (

{title}

{children}
) } /* ── Main page ────────────────────────────────────────────────────── */ export default function EnginePage() { const importRef = useRef(null) const [showPropellants, setShowPropellants] = useState(false) const [showNozzleModal, setShowNozzleModal] = useState(false) const [showChartsModal, setShowChartsModal] = useState(false) const { thermoInputs, setThermoInput, chamber, setChamber, nozzle, setNozzle, injector, setInjector, cooling, setCooling, feedSystem, setFeedSystem, burnTime, setBurnTime, structure, setStructure, allThermo, chamberGeometry: cg, nozzleGeometry: ng, injectorGeometry: ig, coolingResults: cr, feedResults: fr, structureResults: sr, loadDesign, applyPropellant, } = useEngineDesign() function handleExportJSON() { const blob = exportEngineJSON({ thermoInputs, chamber, nozzle, injector, cooling, feedSystem, burnTime, structure, allThermo, chamberGeometry: cg, nozzleGeometry: ng, injectorGeometry: ig, coolingResults: cr, feedResults: fr, structureResults: sr, }) downloadBlob(blob, 'engine-design.json') } async function handleExportODT() { const blob = await exportEngineOdt({ allThermo, chamberGeometry: cg, nozzleGeometry: ng, injectorGeometry: ig, coolingResults: cr, feedResults: fr, }) downloadBlob(blob, 'engine-design.odt') } function handleImport(e) { const file = e.target.files[0] if (!file) return const reader = new FileReader() reader.onload = ev => { try { const inputs = parseEngineImport(ev.target.result) loadDesign(inputs) } catch (err) { alert('Import failed: ' + err.message) } } reader.readAsText(file) e.target.value = '' } return (
{/* Sub-header */}

Engine Design

{/* Three-column layout */}
{/* ── Left: Inputs ── */}
setShowPropellants(true)} className="px-2 py-1 text-xs bg-blue-700 hover:bg-blue-600 text-white rounded transition-colors whitespace-nowrap" > ⛽ Load Propellant } > setThermoInput('p0', v)} unitFamily="pressure" defaultUnitId="MPa" infoKey="p0" placeholder="3" /> setThermoInput('T0', v)} unitFamily="temperature" defaultUnitId="K" infoKey="T0" placeholder="3500" /> setThermoInput('gamma', v)} infoKey="gamma" placeholder="1.2" step="0.01" /> setThermoInput('R', v)} units="J/(kg·K)" infoKey="R_gas" placeholder="360" /> setThermoInput('mdot', v)} unitFamily="massflow" defaultUnitId="kg/s" infoKey="mdot" placeholder="5" /> setThermoInput('F', v)} unitFamily="force" defaultUnitId="kN" infoKey="F_input" placeholder="or enter ṁ" /> setThermoInput('OF', v)} infoKey="OF" placeholder="2.2" step="0.1" /> setThermoInput('At', v)} unitFamily="area" defaultUnitId="cm²" infoKey="At_input" placeholder="auto-solved" /> setThermoInput('eps', v)} infoKey="eps_input" placeholder="auto-solved" step="0.5" /> setThermoInput('pa', v)} unitFamily="pressure" defaultUnitId="kPa" infoKey="pa" placeholder="101.325" /> setChamber(c => ({ ...c, Lstar: v }))} unitFamily="length" defaultUnitId="m" infoKey="Lstar" step="0.05" /> setChamber(c => ({ ...c, contractionRatio: v }))} infoKey="contractionRatio" step="0.5" /> setChamber(c => ({ ...c, convAngleDeg: v }))} units="°" infoKey="convAngleDeg" step="1" /> setNozzle(n => ({ ...n, type: v }))} options={[ { value: 'conical', label: 'Conical' }, { value: 'bell', label: 'Bell (80% length)' }, ]} infoKey="nozzleType" /> {nozzle.type === 'conical' && ( setNozzle(n => ({ ...n, divAngleDeg: v }))} units="°" infoKey="divAngleDeg" step="1" /> )} setInjector(i => ({ ...i, type: v }))} options={[ { value: 'doublet', label: 'Impinging Doublet' }, { value: 'triplet', label: 'Impinging Triplet' }, { value: 'coaxial', label: 'Coaxial' }, { value: 'pintle', label: 'Pintle' }, ]} infoKey="injectorType" /> setInjector(i => ({ ...i, N: Math.max(1, Math.round(v)) }))} infoKey="injectorN" step="1" /> setInjector(i => ({ ...i, dpFraction: v }))} infoKey="dpFraction" step="0.01" /> setInjector(i => ({ ...i, Cd: v }))} infoKey="Cd" step="0.01" /> setInjector(i => ({ ...i, rhoFuel: v }))} unitFamily="density" defaultUnitId="kg/m³" infoKey="rhoFuel_inj" /> setInjector(i => ({ ...i, rhoOx: v }))} unitFamily="density" defaultUnitId="kg/m³" infoKey="rhoOx_inj" /> setCooling(c => ({ ...c, method: v }))} options={[ { value: 'regenerative', label: 'Regenerative' }, { value: 'film', label: 'Film Cooling' }, { value: 'ablative', label: 'Ablative' }, { value: 'uncooled', label: 'Uncooled' }, ]} infoKey="coolingMethod" /> {cooling.method === 'regenerative' && ( <> setCooling(c => ({ ...c, channelCount: Math.max(1, Math.round(v)) }))} infoKey="channelCount" step="1" /> setCooling(c => ({ ...c, mu: v }))} units="Pa·s" step="1e-5" placeholder="6e-5" /> setCooling(c => ({ ...c, cp: v }))} units="J/(kg·K)" step="100" placeholder="2000" /> setCooling(c => ({ ...c, Pr: v }))} units="—" step="0.05" placeholder="0.7" /> setCooling(c => ({ ...c, T_wall: v }))} unitFamily="temperature" defaultUnitId="K" step="50" placeholder="800" /> )} {cooling.method === 'film' && ( setCooling(c => ({ ...c, filmFraction: v }))} infoKey="filmFraction" step="0.01" /> )} {cooling.method === 'ablative' && ( <> setCooling(c => ({ ...c, ablativeMaterial: v }))} options={ABLATIVE_MATERIALS.map(m => ({ value: m.id, label: m.name }))} /> setCooling(c => ({ ...c, ablativeThickness: v }))} units="mm" step="1" placeholder="10" /> )} setFeedSystem(f => ({ ...f, type: v }))} options={[ { value: 'pressure_fed', label: 'Pressure-Fed' }, { value: 'pump_fed', label: 'Pump-Fed' }, ]} infoKey="feedType" /> {feedSystem.type === 'pressure_fed' && ( setFeedSystem(f => ({ ...f, feedFactor: v }))} infoKey="feedFactor" step="0.05" /> )} setFeedSystem(f => ({ ...f, rhoFuel: v }))} unitFamily="density" defaultUnitId="kg/m³" infoKey="rhoFuel_feed" /> setFeedSystem(f => ({ ...f, rhoOx: v }))} unitFamily="density" defaultUnitId="kg/m³" infoKey="rhoOx_feed" /> setBurnTime(v ?? 30)} unitFamily="time" defaultUnitId="s" infoKey="burnTime" step="1" /> setStructure(s => ({ ...s, materialId: v }))} options={STRUCTURAL_MATERIALS.map(m => ({ value: m.id, label: m.name }))} infoKey="structuralMaterial" /> setStructure(s => ({ ...s, safetyFactor: v }))} infoKey="safetyFactor" step="0.1" placeholder="2.0" />
{/* ── Centre: 3D Model + Visualization Buttons ── */}
{/* 3D Model */}
{/* Quick access buttons overlay */}
{cg && ng && ( )} {allThermo?.gamma && allThermo?.R && allThermo?.T0 && allThermo?.p0 && ( )}
{/* ── Right: Results ── */}
{cr ? ( <> {cr.method === 'regenerative' && ( <> )} {cr.method === 'film' && ( <> )} {cr.method === 'ablative' && cr.material && ( <>
Material: {cr.material.name}
{cr.pressureFactor && Math.abs(cr.pressureFactor - 1.0) > 0.01 && ( )}
Eroded {formatValue(cr.erosionMm)} mm ({formatValue(cr.erosionMmMin)}–{formatValue(cr.erosionMmMax)} mm)
Remaining Thickness {formatValue(cr.remainingMm)} mm (worst: {formatValue(cr.remainingMmWorst)} mm)
{(cr.status === 'warning' || cr.status === 'critical') && (
{cr.status === 'critical' ? '🚨 CRITICAL: Liner thickness below safe minimum!' : '⚠️ WARNING: Liner thickness approaching minimum!'}
)} )} {cr.note && (

{cr.note}

)} ) : (

)}
{fr ? ( <> {fr.m_press != null && } {fr.dP_pump != null && } {fr.P_turbine != null && } ) : (

)}
{sr ? ( <>
Material: {sr.material.name}
Engine Dry Mass {formatValue(sr.m_total)} kg
) : (

)}
{showPropellants && ( setShowPropellants(false)} onApply={applyPropellant} description="Select a propellant to pre-fill γ, R, T₀, O/F, and densities." /> )} {/* Nozzle Diagram Modal */} {showNozzleModal && (

Nozzle Cross-Section

)} {/* Performance Charts Modal */} {showChartsModal && (

Performance Analysis

)}
) }