init
This commit is contained in:
687
src/pages/EnginePage.jsx
Normal file
687
src/pages/EnginePage.jsx
Normal file
@@ -0,0 +1,687 @@
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useEngineDesign } from '../hooks/useEngineDesign.js'
|
||||
import {
|
||||
exportEngineJSON,
|
||||
exportEngineOdt,
|
||||
parseEngineImport,
|
||||
downloadBlob,
|
||||
} from '../engine/engineExportImport.js'
|
||||
import DesignSection from '../components/engine/DesignSection.jsx'
|
||||
import EngineModel3D from '../components/engine/EngineModel3D.jsx'
|
||||
import { formatValue } from '../engine/format.js'
|
||||
import { getUnitsForFamily } from '../engine/units.js'
|
||||
import { ENGINE_FIELD_INFO } from '../engine/engineFieldInfo.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 {
|
||||
thermoInputs, setThermoInput,
|
||||
chamber, setChamber,
|
||||
nozzle, setNozzle,
|
||||
injector, setInjector,
|
||||
cooling, setCooling,
|
||||
feedSystem, setFeedSystem,
|
||||
burnTime, setBurnTime,
|
||||
allThermo,
|
||||
chamberGeometry: cg,
|
||||
nozzleGeometry: ng,
|
||||
injectorGeometry: ig,
|
||||
coolingResults: cr,
|
||||
feedResults: fr,
|
||||
loadDesign,
|
||||
} = useEngineDesign()
|
||||
|
||||
function handleExportJSON() {
|
||||
const blob = exportEngineJSON({
|
||||
thermoInputs, chamber, nozzle, injector, cooling, feedSystem, burnTime,
|
||||
allThermo, chamberGeometry: cg, nozzleGeometry: ng,
|
||||
injectorGeometry: ig, coolingResults: cr, feedResults: fr,
|
||||
})
|
||||
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">
|
||||
<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"
|
||||
/>
|
||||
)}
|
||||
{cooling.method === 'film' && (
|
||||
<NumInput
|
||||
label="Film Mass Fraction"
|
||||
value={cooling.filmFraction}
|
||||
onChange={v => setCooling(c => ({ ...c, filmFraction: v }))}
|
||||
infoKey="filmFraction"
|
||||
step="0.01"
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* ── Centre: 3D Model ── */}
|
||||
<div className="flex-1 relative border-r border-slate-700 bg-slate-950/50">
|
||||
<EngineModel3D chamberGeometry={cg} nozzleGeometry={ng} />
|
||||
</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="Channel Area (each)" value={cr.channelArea} unitFamily="area" defaultUnitId="mm²" infoKey="channelArea_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.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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user