941 lines
40 KiB
JavaScript
941 lines
40 KiB
JavaScript
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(
|
||
<div
|
||
ref={popupRef}
|
||
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999 }}
|
||
className="bg-slate-800 border border-slate-600 rounded-lg p-3 shadow-2xl w-56 text-xs"
|
||
>
|
||
<p className="font-semibold text-slate-100 mb-1">{info.name}</p>
|
||
<p className="text-slate-300 mb-2 leading-relaxed">{info.description}</p>
|
||
{info.higher && <p className="text-emerald-400 mb-0.5">↑ {info.higher}</p>}
|
||
{info.lower && <p className="text-amber-400">↓ {info.lower}</p>}
|
||
</div>,
|
||
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 (
|
||
<div className="flex items-center gap-2">
|
||
<label className="text-xs text-slate-400 w-44 shrink-0">{label}</label>
|
||
<input
|
||
type="number"
|
||
value={displayValue}
|
||
step={step}
|
||
placeholder={placeholder ?? ''}
|
||
onChange={e => 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 ? (
|
||
<select
|
||
value={selectedUnit?.id ?? ''}
|
||
onChange={e => setSelectedUnitId(e.target.value)}
|
||
className="px-1 py-0.5 bg-slate-700 border border-slate-600 rounded text-xs text-slate-300
|
||
focus:outline-none cursor-pointer shrink-0"
|
||
>
|
||
{unitList.map(u => <option key={u.id} value={u.id}>{u.label}</option>)}
|
||
</select>
|
||
) : unitList?.[0] ? (
|
||
<span className="text-xs text-slate-500 shrink-0">{unitList[0].label}</span>
|
||
) : units ? (
|
||
<span className="text-xs text-slate-500 shrink-0 whitespace-nowrap">{units}</span>
|
||
) : null}
|
||
{infoKey && (
|
||
<>
|
||
<button
|
||
ref={infoRef}
|
||
onClick={() => setPopupOpen(v => !v)}
|
||
className="text-slate-500 hover:text-slate-300 text-xs shrink-0 leading-none"
|
||
title="Info"
|
||
>ⓘ</button>
|
||
{popupOpen && (
|
||
<InfoPopup infoKey={infoKey} anchorRef={infoRef} onClose={() => setPopupOpen(false)} />
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SelectInput({ label, value, onChange, options, infoKey }) {
|
||
const [popupOpen, setPopupOpen] = useState(false)
|
||
const infoRef = useRef(null)
|
||
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<label className="text-xs text-slate-400 w-44 shrink-0">{label}</label>
|
||
<select
|
||
value={value}
|
||
onChange={e => onChange(e.target.value)}
|
||
className="px-2 py-1 bg-slate-800 border border-slate-600 rounded text-sm text-slate-100
|
||
focus:border-blue-500 focus:outline-none"
|
||
>
|
||
{options.map(o => (
|
||
<option key={o.value} value={o.value}>{o.label}</option>
|
||
))}
|
||
</select>
|
||
{infoKey && (
|
||
<>
|
||
<button
|
||
ref={infoRef}
|
||
onClick={() => setPopupOpen(v => !v)}
|
||
className="text-slate-500 hover:text-slate-300 text-xs shrink-0 leading-none"
|
||
title="Info"
|
||
>ⓘ</button>
|
||
{popupOpen && (
|
||
<InfoPopup infoKey={infoKey} anchorRef={infoRef} onClose={() => setPopupOpen(false)} />
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ── 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 (
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<span className="text-slate-400 w-48 shrink-0">{label}</span>
|
||
<span className={`font-mono ${display === '—' ? 'text-slate-600' : 'text-green-400'}`}>
|
||
{display}
|
||
</span>
|
||
{unitList && unitList.length > 1 ? (
|
||
<select
|
||
value={selectedUnit?.id ?? ''}
|
||
onChange={e => setSelectedUnitId(e.target.value)}
|
||
className="px-1 py-0 bg-slate-700 border border-slate-600 rounded text-xs text-slate-400
|
||
focus:outline-none cursor-pointer"
|
||
>
|
||
{unitList.map(u => <option key={u.id} value={u.id}>{u.label}</option>)}
|
||
</select>
|
||
) : (
|
||
unitLabel && <span className="text-slate-500 text-xs">{unitLabel}</span>
|
||
)}
|
||
{infoKey && (
|
||
<>
|
||
<button
|
||
ref={infoRef}
|
||
onClick={() => setPopupOpen(v => !v)}
|
||
className="text-slate-500 hover:text-slate-300 text-xs leading-none shrink-0"
|
||
title="Info"
|
||
>ⓘ</button>
|
||
{popupOpen && (
|
||
<InfoPopup infoKey={infoKey} anchorRef={infoRef} onClose={() => setPopupOpen(false)} />
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ResultSection({ title, children }) {
|
||
return (
|
||
<div className="mb-5">
|
||
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 pb-1 border-b border-slate-800">
|
||
{title}
|
||
</h3>
|
||
<div className="space-y-1">{children}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ── 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 (
|
||
<div className="flex flex-col flex-1 overflow-hidden">
|
||
{/* Sub-header */}
|
||
<div className="flex items-center justify-between px-5 py-2 bg-slate-900 border-b border-slate-700 shrink-0">
|
||
<h1 className="text-sm font-semibold text-slate-200">Engine Design</h1>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={handleExportJSON}
|
||
className="px-3 py-1 text-xs bg-slate-700 hover:bg-slate-600 rounded text-slate-200 transition-colors"
|
||
>
|
||
↓ JSON
|
||
</button>
|
||
<button
|
||
onClick={handleExportODT}
|
||
className="px-3 py-1 text-xs bg-slate-700 hover:bg-slate-600 rounded text-slate-200 transition-colors"
|
||
>
|
||
↓ ODT
|
||
</button>
|
||
<button
|
||
onClick={() => importRef.current?.click()}
|
||
className="px-3 py-1 text-xs bg-slate-700 hover:bg-slate-600 rounded text-slate-200 transition-colors"
|
||
>
|
||
↑ Import
|
||
</button>
|
||
<input
|
||
ref={importRef}
|
||
type="file"
|
||
accept=".json"
|
||
className="hidden"
|
||
onChange={handleImport}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Three-column layout */}
|
||
<div className="flex flex-1 overflow-hidden">
|
||
{/* ── Left: Inputs ── */}
|
||
<div className="w-[420px] shrink-0 overflow-y-auto p-4 border-r border-slate-700">
|
||
|
||
<DesignSection
|
||
title="Thermodynamic Inputs"
|
||
headerAction={
|
||
<button
|
||
onClick={() => 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
|
||
</button>
|
||
}
|
||
>
|
||
<NumInput
|
||
label="Chamber Pressure (p₀)"
|
||
value={thermoInputs.p0}
|
||
onChange={v => setThermoInput('p0', v)}
|
||
unitFamily="pressure"
|
||
defaultUnitId="MPa"
|
||
infoKey="p0"
|
||
placeholder="3"
|
||
/>
|
||
<NumInput
|
||
label="Chamber Temp (T₀)"
|
||
value={thermoInputs.T0}
|
||
onChange={v => setThermoInput('T0', v)}
|
||
unitFamily="temperature"
|
||
defaultUnitId="K"
|
||
infoKey="T0"
|
||
placeholder="3500"
|
||
/>
|
||
<NumInput
|
||
label="Ratio of Specific Heats (γ)"
|
||
value={thermoInputs.gamma}
|
||
onChange={v => setThermoInput('gamma', v)}
|
||
infoKey="gamma"
|
||
placeholder="1.2"
|
||
step="0.01"
|
||
/>
|
||
<NumInput
|
||
label="Specific Gas Constant (R)"
|
||
value={thermoInputs.R}
|
||
onChange={v => setThermoInput('R', v)}
|
||
units="J/(kg·K)"
|
||
infoKey="R_gas"
|
||
placeholder="360"
|
||
/>
|
||
<NumInput
|
||
label="Mass Flow Rate (ṁ)"
|
||
value={thermoInputs.mdot}
|
||
onChange={v => setThermoInput('mdot', v)}
|
||
unitFamily="massflow"
|
||
defaultUnitId="kg/s"
|
||
infoKey="mdot"
|
||
placeholder="5"
|
||
/>
|
||
<NumInput
|
||
label="Thrust (F)"
|
||
value={thermoInputs.F}
|
||
onChange={v => setThermoInput('F', v)}
|
||
unitFamily="force"
|
||
defaultUnitId="kN"
|
||
infoKey="F_input"
|
||
placeholder="or enter ṁ"
|
||
/>
|
||
<NumInput
|
||
label="O/F Ratio"
|
||
value={thermoInputs.OF}
|
||
onChange={v => setThermoInput('OF', v)}
|
||
infoKey="OF"
|
||
placeholder="2.2"
|
||
step="0.1"
|
||
/>
|
||
<NumInput
|
||
label="Throat Area (Aₜ)"
|
||
value={thermoInputs.At}
|
||
onChange={v => setThermoInput('At', v)}
|
||
unitFamily="area"
|
||
defaultUnitId="cm²"
|
||
infoKey="At_input"
|
||
placeholder="auto-solved"
|
||
/>
|
||
<NumInput
|
||
label="Expansion Ratio (ε)"
|
||
value={thermoInputs.eps}
|
||
onChange={v => setThermoInput('eps', v)}
|
||
infoKey="eps_input"
|
||
placeholder="auto-solved"
|
||
step="0.5"
|
||
/>
|
||
<NumInput
|
||
label="Ambient Pressure (pₐ)"
|
||
value={thermoInputs.pa}
|
||
onChange={v => setThermoInput('pa', v)}
|
||
unitFamily="pressure"
|
||
defaultUnitId="kPa"
|
||
infoKey="pa"
|
||
placeholder="101.325"
|
||
/>
|
||
</DesignSection>
|
||
|
||
<DesignSection title="Combustion Chamber">
|
||
<NumInput
|
||
label="Characteristic Length (L*)"
|
||
value={chamber.Lstar}
|
||
onChange={v => setChamber(c => ({ ...c, Lstar: v }))}
|
||
unitFamily="length"
|
||
defaultUnitId="m"
|
||
infoKey="Lstar"
|
||
step="0.05"
|
||
/>
|
||
<NumInput
|
||
label="Contraction Ratio"
|
||
value={chamber.contractionRatio}
|
||
onChange={v => setChamber(c => ({ ...c, contractionRatio: v }))}
|
||
infoKey="contractionRatio"
|
||
step="0.5"
|
||
/>
|
||
<NumInput
|
||
label="Convergent Half-Angle"
|
||
value={chamber.convAngleDeg}
|
||
onChange={v => setChamber(c => ({ ...c, convAngleDeg: v }))}
|
||
units="°"
|
||
infoKey="convAngleDeg"
|
||
step="1"
|
||
/>
|
||
</DesignSection>
|
||
|
||
<DesignSection title="Nozzle">
|
||
<SelectInput
|
||
label="Nozzle Type"
|
||
value={nozzle.type}
|
||
onChange={v => setNozzle(n => ({ ...n, type: v }))}
|
||
options={[
|
||
{ value: 'conical', label: 'Conical' },
|
||
{ value: 'bell', label: 'Bell (80% length)' },
|
||
]}
|
||
infoKey="nozzleType"
|
||
/>
|
||
{nozzle.type === 'conical' && (
|
||
<NumInput
|
||
label="Divergence Half-Angle"
|
||
value={nozzle.divAngleDeg}
|
||
onChange={v => setNozzle(n => ({ ...n, divAngleDeg: v }))}
|
||
units="°"
|
||
infoKey="divAngleDeg"
|
||
step="1"
|
||
/>
|
||
)}
|
||
</DesignSection>
|
||
|
||
<DesignSection title="Injector">
|
||
<SelectInput
|
||
label="Injector Type"
|
||
value={injector.type}
|
||
onChange={v => 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"
|
||
/>
|
||
<NumInput
|
||
label="Number of Elements (N)"
|
||
value={injector.N}
|
||
onChange={v => setInjector(i => ({ ...i, N: Math.max(1, Math.round(v)) }))}
|
||
infoKey="injectorN"
|
||
step="1"
|
||
/>
|
||
<NumInput
|
||
label="ΔP/p₀ Fraction"
|
||
value={injector.dpFraction}
|
||
onChange={v => setInjector(i => ({ ...i, dpFraction: v }))}
|
||
infoKey="dpFraction"
|
||
step="0.01"
|
||
/>
|
||
<NumInput
|
||
label="Discharge Coefficient (Cd)"
|
||
value={injector.Cd}
|
||
onChange={v => setInjector(i => ({ ...i, Cd: v }))}
|
||
infoKey="Cd"
|
||
step="0.01"
|
||
/>
|
||
<NumInput
|
||
label="Fuel Density (ρ_f)"
|
||
value={injector.rhoFuel}
|
||
onChange={v => setInjector(i => ({ ...i, rhoFuel: v }))}
|
||
unitFamily="density"
|
||
defaultUnitId="kg/m³"
|
||
infoKey="rhoFuel_inj"
|
||
/>
|
||
<NumInput
|
||
label="Oxidiser Density (ρ_ox)"
|
||
value={injector.rhoOx}
|
||
onChange={v => setInjector(i => ({ ...i, rhoOx: v }))}
|
||
unitFamily="density"
|
||
defaultUnitId="kg/m³"
|
||
infoKey="rhoOx_inj"
|
||
/>
|
||
</DesignSection>
|
||
|
||
<DesignSection title="Cooling">
|
||
<SelectInput
|
||
label="Cooling Method"
|
||
value={cooling.method}
|
||
onChange={v => 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' && (
|
||
<>
|
||
<NumInput
|
||
label="Channel Count"
|
||
value={cooling.channelCount}
|
||
onChange={v => setCooling(c => ({ ...c, channelCount: Math.max(1, Math.round(v)) }))}
|
||
infoKey="channelCount"
|
||
step="1"
|
||
/>
|
||
<NumInput
|
||
label="Dynamic Viscosity (μ)"
|
||
value={cooling.mu}
|
||
onChange={v => setCooling(c => ({ ...c, mu: v }))}
|
||
units="Pa·s"
|
||
step="1e-5"
|
||
placeholder="6e-5"
|
||
/>
|
||
<NumInput
|
||
label="Specific Heat (cₚ)"
|
||
value={cooling.cp}
|
||
onChange={v => setCooling(c => ({ ...c, cp: v }))}
|
||
units="J/(kg·K)"
|
||
step="100"
|
||
placeholder="2000"
|
||
/>
|
||
<NumInput
|
||
label="Prandtl Number (Pr)"
|
||
value={cooling.Pr}
|
||
onChange={v => setCooling(c => ({ ...c, Pr: v }))}
|
||
units="—"
|
||
step="0.05"
|
||
placeholder="0.7"
|
||
/>
|
||
<NumInput
|
||
label="Wall Temperature (T_wall)"
|
||
value={cooling.T_wall}
|
||
onChange={v => setCooling(c => ({ ...c, T_wall: v }))}
|
||
unitFamily="temperature"
|
||
defaultUnitId="K"
|
||
step="50"
|
||
placeholder="800"
|
||
/>
|
||
</>
|
||
)}
|
||
{cooling.method === 'film' && (
|
||
<NumInput
|
||
label="Film Mass Fraction"
|
||
value={cooling.filmFraction}
|
||
onChange={v => setCooling(c => ({ ...c, filmFraction: v }))}
|
||
infoKey="filmFraction"
|
||
step="0.01"
|
||
/>
|
||
)}
|
||
{cooling.method === 'ablative' && (
|
||
<>
|
||
<SelectInput
|
||
label="Liner Material"
|
||
value={cooling.ablativeMaterial}
|
||
onChange={v => setCooling(c => ({ ...c, ablativeMaterial: v }))}
|
||
options={ABLATIVE_MATERIALS.map(m => ({ value: m.id, label: m.name }))}
|
||
/>
|
||
<NumInput
|
||
label="Initial Thickness"
|
||
value={cooling.ablativeThickness}
|
||
onChange={v => setCooling(c => ({ ...c, ablativeThickness: v }))}
|
||
units="mm"
|
||
step="1"
|
||
placeholder="10"
|
||
/>
|
||
</>
|
||
)}
|
||
</DesignSection>
|
||
|
||
<DesignSection title="Feed System">
|
||
<SelectInput
|
||
label="Feed System"
|
||
value={feedSystem.type}
|
||
onChange={v => setFeedSystem(f => ({ ...f, type: v }))}
|
||
options={[
|
||
{ value: 'pressure_fed', label: 'Pressure-Fed' },
|
||
{ value: 'pump_fed', label: 'Pump-Fed' },
|
||
]}
|
||
infoKey="feedType"
|
||
/>
|
||
{feedSystem.type === 'pressure_fed' && (
|
||
<NumInput
|
||
label="Feed Pressure Factor"
|
||
value={feedSystem.feedFactor}
|
||
onChange={v => setFeedSystem(f => ({ ...f, feedFactor: v }))}
|
||
infoKey="feedFactor"
|
||
step="0.05"
|
||
/>
|
||
)}
|
||
<NumInput
|
||
label="Fuel Density (ρ_f)"
|
||
value={feedSystem.rhoFuel}
|
||
onChange={v => setFeedSystem(f => ({ ...f, rhoFuel: v }))}
|
||
unitFamily="density"
|
||
defaultUnitId="kg/m³"
|
||
infoKey="rhoFuel_feed"
|
||
/>
|
||
<NumInput
|
||
label="Oxidiser Density (ρ_ox)"
|
||
value={feedSystem.rhoOx}
|
||
onChange={v => setFeedSystem(f => ({ ...f, rhoOx: v }))}
|
||
unitFamily="density"
|
||
defaultUnitId="kg/m³"
|
||
infoKey="rhoOx_feed"
|
||
/>
|
||
<NumInput
|
||
label="Burn Time"
|
||
value={burnTime}
|
||
onChange={v => setBurnTime(v ?? 30)}
|
||
unitFamily="time"
|
||
defaultUnitId="s"
|
||
infoKey="burnTime"
|
||
step="1"
|
||
/>
|
||
</DesignSection>
|
||
|
||
<DesignSection title="Structural">
|
||
<SelectInput
|
||
label="Material"
|
||
value={structure.materialId}
|
||
onChange={v => setStructure(s => ({ ...s, materialId: v }))}
|
||
options={STRUCTURAL_MATERIALS.map(m => ({ value: m.id, label: m.name }))}
|
||
infoKey="structuralMaterial"
|
||
/>
|
||
<NumInput
|
||
label="Safety Factor"
|
||
value={structure.safetyFactor}
|
||
onChange={v => setStructure(s => ({ ...s, safetyFactor: v }))}
|
||
infoKey="safetyFactor"
|
||
step="0.1"
|
||
placeholder="2.0"
|
||
/>
|
||
</DesignSection>
|
||
</div>
|
||
|
||
{/* ── Centre: 3D Model + Visualization Buttons ── */}
|
||
<div className="flex-1 border-r border-slate-700 bg-slate-950/50 flex flex-col overflow-hidden">
|
||
{/* 3D Model */}
|
||
<div className="flex-1 relative flex flex-col">
|
||
<ErrorBoundary>
|
||
<EngineModel3D chamberGeometry={cg} nozzleGeometry={ng} />
|
||
</ErrorBoundary>
|
||
|
||
{/* Quick access buttons overlay */}
|
||
<div className="absolute bottom-4 left-4 right-4 flex gap-2 pointer-events-auto">
|
||
{cg && ng && (
|
||
<button
|
||
onClick={() => setShowNozzleModal(true)}
|
||
className="px-3 py-2 text-xs bg-blue-700 hover:bg-blue-600 text-white rounded transition-colors whitespace-nowrap"
|
||
>
|
||
📐 Nozzle Cross-Section
|
||
</button>
|
||
)}
|
||
{allThermo?.gamma && allThermo?.R && allThermo?.T0 && allThermo?.p0 && (
|
||
<button
|
||
onClick={() => setShowChartsModal(true)}
|
||
className="px-3 py-2 text-xs bg-indigo-700 hover:bg-indigo-600 text-white rounded transition-colors whitespace-nowrap"
|
||
>
|
||
📊 Performance Charts
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Right: Results ── */}
|
||
<div className="w-[380px] shrink-0 overflow-y-auto p-4">
|
||
|
||
<ResultSection title="Thermodynamic Performance">
|
||
<ResultRow label="Throat Area (Aₜ)" value={allThermo.At} unitFamily="area" defaultUnitId="cm²" infoKey="At_result" />
|
||
<ResultRow label="Exit Area (Aₑ)" value={allThermo.Ae} unitFamily="area" defaultUnitId="cm²" infoKey="Ae_result" />
|
||
<ResultRow label="Expansion Ratio (ε)" value={allThermo.eps} unit="—" infoKey="eps_result" />
|
||
<ResultRow label="Exit Mach (Mₑ)" value={allThermo.Me} unit="—" infoKey="Me_result" />
|
||
<ResultRow label="Exit Temp (Tₑ)" value={allThermo.Te} unitFamily="temperature" defaultUnitId="K" infoKey="Te_result" />
|
||
<ResultRow label="Exit Pressure (pₑ)" value={allThermo.pe} unitFamily="pressure" defaultUnitId="kPa" infoKey="pe_result" />
|
||
<ResultRow label="Exhaust Velocity (Vₑ)" value={allThermo.Ve} unitFamily="velocity" defaultUnitId="m/s" infoKey="Ve_result" />
|
||
<ResultRow label="Thrust (F)" value={allThermo.F} unitFamily="force" defaultUnitId="kN" infoKey="F_result" />
|
||
<ResultRow label="Specific Impulse (Isp)" value={allThermo.Isp} unit="s" infoKey="Isp_result" />
|
||
<ResultRow label="c*" value={allThermo.cstar} unitFamily="velocity" defaultUnitId="m/s" infoKey="cstar_result" />
|
||
<ResultRow label="Thrust Coefficient (CF)" value={allThermo.CF} unit="—" infoKey="CF_result" />
|
||
<ResultRow label="Fuel Flow (ṁ_f)" value={allThermo.mdot_f} unitFamily="massflow" defaultUnitId="kg/s" infoKey="mdot_f_result" />
|
||
<ResultRow label="Ox Flow (ṁ_ox)" value={allThermo.mdot_ox} unitFamily="massflow" defaultUnitId="kg/s" infoKey="mdot_ox_result" />
|
||
</ResultSection>
|
||
|
||
<ResultSection title="Chamber Geometry">
|
||
<ResultRow label="Chamber Diameter (Dc)" value={cg?.Dc} unitFamily="length" defaultUnitId="mm" infoKey="Dc_result" />
|
||
<ResultRow label="Throat Diameter (Dt)" value={cg?.Dt} unitFamily="length" defaultUnitId="mm" infoKey="Dt_result" />
|
||
<ResultRow label="Contraction Ratio" value={cg?.contractionRatio} unit="—" infoKey="contractionRatio_result" />
|
||
<ResultRow label="Total Length (Lc)" value={cg?.Lc} unitFamily="length" defaultUnitId="mm" infoKey="Lc_result" />
|
||
<ResultRow label="Cylindrical Length" value={cg?.L_cyl} unitFamily="length" defaultUnitId="mm" infoKey="L_cyl_result" />
|
||
<ResultRow label="Convergent Length" value={cg?.L_conv} unitFamily="length" defaultUnitId="mm" infoKey="L_conv_result" />
|
||
<ResultRow label="Chamber Volume" value={cg?.Vc} unitFamily="volume" defaultUnitId="cm³" infoKey="Vc_result" />
|
||
</ResultSection>
|
||
|
||
<ResultSection title="Nozzle Geometry">
|
||
<ResultRow label="Throat Diameter (Dt)" value={ng?.Dt} unitFamily="length" defaultUnitId="mm" infoKey="Dt_result" />
|
||
<ResultRow label="Exit Diameter (De)" value={ng?.De} unitFamily="length" defaultUnitId="mm" infoKey="De_result" />
|
||
<ResultRow label="Nozzle Length (Ln)" value={ng?.Ln} unitFamily="length" defaultUnitId="mm" infoKey="Ln_result" />
|
||
</ResultSection>
|
||
|
||
<ResultSection title="Injector">
|
||
<ResultRow label="Pressure Drop (ΔP)" value={ig?.deltaP} unitFamily="pressure" defaultUnitId="kPa" infoKey="deltaP_result" />
|
||
<ResultRow label="Fuel Jet Velocity" value={ig?.v_f} unitFamily="velocity" defaultUnitId="m/s" infoKey="v_f_result" />
|
||
<ResultRow label="Oxidiser Jet Velocity" value={ig?.v_ox} unitFamily="velocity" defaultUnitId="m/s" infoKey="v_ox_result" />
|
||
<ResultRow label="Fuel Orifice Diameter" value={ig?.d_f} unitFamily="length" defaultUnitId="mm" infoKey="d_f_result" />
|
||
<ResultRow label="Oxidiser Orifice Diameter" value={ig?.d_ox} unitFamily="length" defaultUnitId="mm" infoKey="d_ox_result" />
|
||
</ResultSection>
|
||
|
||
<ResultSection title="Cooling">
|
||
{cr ? (
|
||
<>
|
||
{cr.method === 'regenerative' && (
|
||
<>
|
||
<ResultRow label="Est. Heat Flux" value={cr.q_est} unit="W/m²" infoKey="q_est_result" />
|
||
<ResultRow label="Total Heat Load" value={cr.q_total} unit="W" infoKey="q_total_result" />
|
||
<ResultRow label="Channel Count" value={cr.channelCount} unit="—" infoKey="channelCount_result" />
|
||
<ResultRow label="Heat load / channel" value={cr.q_perChannel} unit="W" infoKey="q_perChannel_result" />
|
||
</>
|
||
)}
|
||
{cr.method === 'film' && (
|
||
<>
|
||
<ResultRow label="Film Mass Flow" value={cr.mdot_film} unitFamily="massflow" defaultUnitId="g/s" infoKey="mdot_film_result" />
|
||
<ResultRow label="Est. Isp Penalty" value={cr.ispPenalty} unit="%" infoKey="ispPenalty_result" />
|
||
</>
|
||
)}
|
||
{cr.method === 'ablative' && cr.material && (
|
||
<>
|
||
<div className="text-sm mb-2">
|
||
<span className="text-slate-400">Material: </span>
|
||
<span className="text-slate-100 font-semibold">{cr.material.name}</span>
|
||
</div>
|
||
{cr.pressureFactor && Math.abs(cr.pressureFactor - 1.0) > 0.01 && (
|
||
<ResultRow label="Pressure Correction" value={cr.pressureFactor} unit="×" />
|
||
)}
|
||
<ResultRow label="Erosion Rate" value={cr.effectiveRate} unit="mm/s" />
|
||
<ResultRow label="Initial Thickness" value={cr.ablativeThickness} unit="mm" />
|
||
<div className="flex items-center gap-2 text-sm py-1.5">
|
||
<span className="text-slate-400 w-48 shrink-0">Eroded</span>
|
||
<span className="font-mono text-green-400">
|
||
{formatValue(cr.erosionMm)} mm
|
||
</span>
|
||
<span className="text-xs text-slate-500">
|
||
({formatValue(cr.erosionMmMin)}–{formatValue(cr.erosionMmMax)} mm)
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-sm py-1.5">
|
||
<span className="text-slate-400 w-48 shrink-0">Remaining Thickness</span>
|
||
<span className={`font-mono font-semibold ${
|
||
cr.status === 'critical' ? 'text-red-400' :
|
||
cr.status === 'warning' ? 'text-amber-400' :
|
||
'text-green-400'
|
||
}`}>
|
||
{formatValue(cr.remainingMm)} mm
|
||
</span>
|
||
<span className="text-xs text-slate-500">
|
||
(worst: {formatValue(cr.remainingMmWorst)} mm)
|
||
</span>
|
||
</div>
|
||
{(cr.status === 'warning' || cr.status === 'critical') && (
|
||
<div className={`text-xs px-3 py-2 rounded mt-2 ${
|
||
cr.status === 'critical'
|
||
? 'bg-red-900/30 border border-red-700 text-red-200'
|
||
: 'bg-amber-900/30 border border-amber-700 text-amber-200'
|
||
}`}>
|
||
{cr.status === 'critical'
|
||
? '🚨 CRITICAL: Liner thickness below safe minimum!'
|
||
: '⚠️ WARNING: Liner thickness approaching minimum!'}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
{cr.note && (
|
||
<p className="text-xs text-slate-400 italic mt-1">{cr.note}</p>
|
||
)}
|
||
</>
|
||
) : (
|
||
<p className="text-xs text-slate-600">—</p>
|
||
)}
|
||
</ResultSection>
|
||
|
||
<ResultSection title="Feed System">
|
||
{fr ? (
|
||
<>
|
||
<ResultRow label="Tank Pressure" value={fr.p_tank} unitFamily="pressure" defaultUnitId="MPa" infoKey="p_tank_result" />
|
||
<ResultRow label="Fuel Volume" value={fr.V_fuel} unitFamily="volume" defaultUnitId="L" infoKey="V_fuel_result" />
|
||
<ResultRow label="Oxidiser Volume" value={fr.V_ox} unitFamily="volume" defaultUnitId="L" infoKey="V_ox_result" />
|
||
<ResultRow label="Total Propellant Volume" value={fr.V_prop} unitFamily="volume" defaultUnitId="L" infoKey="V_prop_result" />
|
||
{fr.m_press != null && <ResultRow label="Pressurant Mass" value={fr.m_press} unitFamily="mass" defaultUnitId="kg" infoKey="m_press_result" />}
|
||
{fr.dP_pump != null && <ResultRow label="Pump ΔP" value={fr.dP_pump} unitFamily="pressure" defaultUnitId="MPa" infoKey="dP_pump_result" />}
|
||
{fr.P_turbine != null && <ResultRow label="Est. Turbine Power" value={fr.P_turbine / 1000} unit="kW" infoKey="P_turbine_result" />}
|
||
</>
|
||
) : (
|
||
<p className="text-xs text-slate-600">—</p>
|
||
)}
|
||
</ResultSection>
|
||
|
||
<ResultSection title="Structural Sizing">
|
||
{sr ? (
|
||
<>
|
||
<div className="text-sm mb-2">
|
||
<span className="text-slate-400">Material: </span>
|
||
<span className="text-slate-100 font-semibold">{sr.material.name}</span>
|
||
</div>
|
||
<ResultRow label="Safety Factor" value={sr.safetyFactor} unit="—" />
|
||
<ResultRow label="Allowable Stress" value={sr.allowableStress / 1e6} unit="MPa" />
|
||
<div className="border-t border-slate-700 my-2 pt-2">
|
||
<ResultRow label="Chamber Wall Thickness" value={sr.t_chamber * 1000} unit="mm" />
|
||
<ResultRow label="Throat Wall Thickness" value={sr.t_throat * 1000} unit="mm" />
|
||
<ResultRow label="Nozzle Exit Wall Thickness" value={sr.t_nozzle_exit * 1000} unit="mm" />
|
||
</div>
|
||
<div className="border-t border-slate-700 my-2 pt-2">
|
||
<ResultRow label="Chamber Mass" value={sr.m_chamber_cyl + sr.m_convergent} unitFamily="mass" defaultUnitId="kg" />
|
||
<ResultRow label="Nozzle Mass" value={sr.m_nozzle} unitFamily="mass" defaultUnitId="kg" />
|
||
<ResultRow label="Injector Plate Mass" value={sr.m_injector} unitFamily="mass" defaultUnitId="kg" />
|
||
</div>
|
||
<div className="border-t border-slate-700 mt-2 pt-2">
|
||
<div className="flex items-center gap-2 text-sm bg-blue-900/30 border border-blue-700 rounded px-3 py-2">
|
||
<span className="text-slate-400">Engine Dry Mass</span>
|
||
<span className="font-mono font-semibold text-blue-300">
|
||
{formatValue(sr.m_total)} kg
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<p className="text-xs text-slate-600">—</p>
|
||
)}
|
||
</ResultSection>
|
||
</div>
|
||
</div>
|
||
|
||
{showPropellants && (
|
||
<PropellantModal
|
||
onClose={() => setShowPropellants(false)}
|
||
onApply={applyPropellant}
|
||
description="Select a propellant to pre-fill γ, R, T₀, O/F, and densities."
|
||
/>
|
||
)}
|
||
|
||
{/* Nozzle Diagram Modal */}
|
||
{showNozzleModal && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-slate-900 rounded-lg shadow-2xl border border-slate-700 w-full max-w-4xl max-h-[90vh] flex flex-col">
|
||
<div className="flex items-center justify-between p-4 border-b border-slate-700 shrink-0">
|
||
<h2 className="text-lg font-semibold text-slate-100">Nozzle Cross-Section</h2>
|
||
<button
|
||
onClick={() => setShowNozzleModal(false)}
|
||
className="text-slate-400 hover:text-slate-200 text-xl"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className="flex-1 overflow-auto p-6 bg-slate-800">
|
||
<ErrorBoundary>
|
||
<NozzleDiagram chamberGeometry={cg} nozzleGeometry={ng} nozzleType={nozzle.type} />
|
||
</ErrorBoundary>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Performance Charts Modal */}
|
||
{showChartsModal && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-slate-900 rounded-lg shadow-2xl border border-slate-700 w-full max-w-5xl max-h-[90vh] flex flex-col">
|
||
<div className="flex items-center justify-between p-4 border-b border-slate-700 shrink-0">
|
||
<h2 className="text-lg font-semibold text-slate-100">Performance Analysis</h2>
|
||
<button
|
||
onClick={() => setShowChartsModal(false)}
|
||
className="text-slate-400 hover:text-slate-200 text-xl"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className="flex-1 overflow-auto p-6 bg-slate-800">
|
||
<ErrorBoundary>
|
||
<PerformanceCharts
|
||
allThermo={allThermo}
|
||
coolingResults={cr}
|
||
burnTime={burnTime}
|
||
nozzleType={nozzle.type}
|
||
/>
|
||
</ErrorBoundary>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|