diff --git a/install-service.sh b/install-service.sh new file mode 100644 index 0000000..ee69f34 --- /dev/null +++ b/install-service.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +set -e + +# Rocketry Service Installer +# Installs the React SPA as a systemd service on the system + +# Preflight checks +command -v node >/dev/null || { echo "Error: node not found"; exit 1; } +command -v npm >/dev/null || { echo "Error: npm not found"; exit 1; } +command -v sudo >/dev/null || { echo "Error: sudo not found"; exit 1; } + +echo "Rocketry Service Installer" +echo "==========================" +echo "" + +# Port prompt +read -p "Port to run on [default: 8080]: " PORT +PORT=${PORT:-8080} + +# Validate port +if ! [[ "$PORT" =~ ^[0-9]+$ ]] || [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then + echo "Error: Invalid port number. Must be between 1 and 65535." + exit 1 +fi + +echo "" +echo "Building application..." +npm run build + +# Install directory +INSTALL_DIR="/opt/rocketry" +echo "Installing to $INSTALL_DIR..." +sudo mkdir -p "$INSTALL_DIR" +sudo cp -r dist "$INSTALL_DIR/" + +# Write server.js to /opt/rocketry/server.js +echo "Writing server.js..." +sudo tee "$INSTALL_DIR/server.js" > /dev/null <<'SERVEREOF' +#!/usr/bin/env node +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +const PORT = process.env.PORT || 8080; +const DIST_DIR = path.join(__dirname, 'dist'); + +const MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2', + '.json': 'application/json', +}; + +const server = http.createServer((req, res) => { + // Normalize URL + let urlPath = decodeURIComponent(req.url); + if (urlPath === '/') { + urlPath = '/index.html'; + } + + // Resolve file path + const filePath = path.join(DIST_DIR, urlPath); + + // Security check: ensure we're within DIST_DIR + if (!filePath.startsWith(DIST_DIR)) { + res.writeHead(403, { 'Content-Type': 'text/plain' }); + res.end('Forbidden'); + return; + } + + fs.stat(filePath, (err, stats) => { + if (err) { + // File not found; serve index.html for SPA routing (unless it's a file with extension) + const ext = path.extname(urlPath); + if (ext && ext !== '.html') { + // Likely a missing asset file + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + return; + } + + // SPA fallback: serve index.html + const indexPath = path.join(DIST_DIR, 'index.html'); + fs.readFile(indexPath, 'utf-8', (readErr, data) => { + if (readErr) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Internal Server Error'); + return; + } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(data); + }); + return; + } + + if (stats.isDirectory()) { + // Try to serve index.html from the directory + const indexPath = path.join(filePath, 'index.html'); + fs.readFile(indexPath, 'utf-8', (readErr, data) => { + if (readErr) { + // Directory exists but no index.html; fall back to SPA index + const mainIndexPath = path.join(DIST_DIR, 'index.html'); + fs.readFile(mainIndexPath, 'utf-8', (mainErr, mainData) => { + if (mainErr) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + return; + } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(mainData); + }); + return; + } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(data); + }); + return; + } + + // Serve file with appropriate MIME type + const ext = path.extname(filePath).toLowerCase(); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + + fs.readFile(filePath, (readErr, data) => { + if (readErr) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Internal Server Error'); + return; + } + res.writeHead(200, { 'Content-Type': contentType }); + res.end(data); + }); + }); +}); + +server.listen(PORT, () => { + console.log(`Rocketry server listening on http://localhost:${PORT}`); +}); + +server.on('error', (err) => { + if (err.code === 'EACCES') { + console.error(`Error: Permission denied. Port ${PORT} may require elevated privileges.`); + } else if (err.code === 'EADDRINUSE') { + console.error(`Error: Port ${PORT} is already in use.`); + } else { + console.error('Server error:', err.message); + } + process.exit(1); +}); +SERVEREOF + +# Make server.js executable +sudo chmod +x "$INSTALL_DIR/server.js" + +# Write systemd unit file +echo "Writing systemd service file..." +sudo bash -c "cat > /etc/systemd/system/rocketry.service" < Fuels / Oxidisers + + `block px-4 py-2 text-sm transition-colors ${ + isActive + ? 'bg-slate-700 text-white' + : 'text-slate-300 hover:bg-slate-700 hover:text-white' + }` + } + > + Equations + @@ -99,6 +112,7 @@ export default function App() { } /> } /> } /> + } /> ) diff --git a/src/components/ErrorBoundary.jsx b/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..a80f92b --- /dev/null +++ b/src/components/ErrorBoundary.jsx @@ -0,0 +1,20 @@ +import { Component } from 'react' + +export default class ErrorBoundary extends Component { + state = { error: null } + + static getDerivedStateFromError(e) { + return { error: e } + } + + render() { + if (this.state.error) { + return ( +
+ 3D view unavailable +
+ ) + } + return this.props.children + } +} diff --git a/src/engine/engineDesignCalcs.js b/src/engine/engineDesignCalcs.js index 8f9972f..221ef47 100644 --- a/src/engine/engineDesignCalcs.js +++ b/src/engine/engineDesignCalcs.js @@ -96,10 +96,7 @@ export function calcCooling(thermo, cooling, chamberGeom) { const { Dt, Dc, Lc } = chamberGeom // Simplified Bartz heat flux [W/m²] using typical exhaust gas properties - const mu = 6e-5 // Pa·s — typical rocket exhaust dynamic viscosity - const cp = 2000 // J/(kg·K) - const Pr = 0.7 - const T_wall = 800 // K — assumed hot-gas-side wall temperature + const { mu = 6e-5, cp = 2000, Pr = 0.7, T_wall = 800 } = cooling const q_est = (0.026 / Math.pow(Dt, 0.2)) * (Math.pow(mu, 0.2) * cp / Math.pow(Pr, 0.6)) * @@ -110,9 +107,9 @@ export function calcCooling(thermo, cooling, chamberGeom) { const q_total = q_est * chamberArea const { channelCount } = cooling - const channelArea = channelCount > 0 ? q_total / channelCount : 0 + const q_perChannel = channelCount > 0 ? q_total / channelCount : 0 - return { method, q_est, q_total, channelCount, channelArea } + return { method, q_est, q_total, channelCount, q_perChannel } } if (method === 'film') { diff --git a/src/engine/equations.js b/src/engine/equations.js index 073bef8..857a9be 100644 --- a/src/engine/equations.js +++ b/src/engine/equations.js @@ -16,6 +16,7 @@ export const EQUATIONS = [ name: 'Fundamental Thrust Equation', formula: 'F = ṁ·Vₑ + (pₑ − pₐ)·Aₑ', category: 'Thrust', + description: 'Calculates total thrust from momentum thrust and pressure thrust. Used when exit conditions and mass flow are known.', variables: ['F', 'mdot', 'Ve', 'pe', 'pa', 'Ae'], solvers: { F: v => v.mdot * v.Ve + (v.pe - v.pa) * v.Ae, @@ -40,6 +41,7 @@ export const EQUATIONS = [ name: 'Thrust from Isp', formula: 'F = ṁ·Isp·g₀', category: 'Thrust', + description: 'Relates thrust, mass flow, and specific impulse. Commonly used when Isp is known instead of exhaust velocity.', variables: ['F', 'mdot', 'Isp', 'g0'], solvers: { F: v => v.mdot * v.Isp * v.g0, @@ -55,6 +57,7 @@ export const EQUATIONS = [ name: 'Effective Exhaust Velocity', formula: 'cₑ = F / ṁ', category: 'Thrust', + description: 'Defines effective exhaust velocity as thrust divided by mass flow. Includes both momentum and pressure thrust effects.', variables: ['ceff', 'F', 'mdot'], solvers: { ceff: v => v.F / v.mdot, @@ -69,6 +72,7 @@ export const EQUATIONS = [ name: 'Isp from Effective Exhaust Velocity', formula: 'Isp = cₑ / g₀', category: 'Specific Impulse', + description: 'Converts effective exhaust velocity to specific impulse. Related by the standard gravitational acceleration.', variables: ['Isp', 'ceff', 'g0'], solvers: { Isp: v => v.ceff / v.g0, @@ -82,6 +86,7 @@ export const EQUATIONS = [ name: 'Isp from Thrust & Mass Flow', formula: 'Isp = F / (ṁ·g₀)', category: 'Specific Impulse', + description: 'Directly calculates specific impulse from thrust and mass flow. Fundamental performance metric for rocket engines.', variables: ['Isp', 'F', 'mdot', 'g0'], solvers: { Isp: v => v.F / (v.mdot * v.g0), @@ -97,6 +102,7 @@ export const EQUATIONS = [ name: 'Characteristic Velocity', formula: 'c* = p₀·Aₜ / ṁ', category: 'Nozzle Performance', + description: 'Defines characteristic velocity as a measure of combustion chamber performance. Independent of nozzle expansion.', variables: ['cstar', 'p0', 'At', 'mdot'], solvers: { cstar: v => (v.p0 * v.At) / v.mdot, @@ -111,6 +117,7 @@ export const EQUATIONS = [ name: 'c* from Thrust Coefficient & Isp', formula: 'c* = Isp·g₀ / Cꜰ', category: 'Nozzle Performance', + description: 'Relates characteristic velocity to thrust coefficient and specific impulse. Useful for nozzle analysis.', variables: ['cstar', 'Isp', 'g0', 'CF'], solvers: { cstar: v => (v.Isp * v.g0) / v.CF, @@ -126,6 +133,7 @@ export const EQUATIONS = [ name: 'Thrust Coefficient', formula: 'Cꜰ = F / (p₀·Aₜ)', category: 'Nozzle Performance', + description: 'Defines thrust coefficient as a dimensionless nozzle performance factor. Typical values are 1.4–1.9 for ideal nozzles.', variables: ['CF', 'F', 'p0', 'At'], solvers: { CF: v => v.F / (v.p0 * v.At), @@ -141,6 +149,7 @@ export const EQUATIONS = [ name: 'Tsiolkovsky Rocket Equation (cₑ)', formula: 'Δv = cₑ·ln(m₀/mf)', category: 'Rocket Equation', + description: 'The rocket equation in terms of effective exhaust velocity. Calculates delta-v available from propellant mass ratio.', variables: ['dv', 'ceff', 'm0', 'mf'], solvers: { dv: v => v.ceff * Math.log(v.m0 / v.mf), @@ -155,6 +164,7 @@ export const EQUATIONS = [ name: 'Tsiolkovsky Rocket Equation (Isp)', formula: 'Δv = Isp·g₀·ln(m₀/mf)', category: 'Rocket Equation', + description: 'The classic Tsiolkovsky rocket equation using specific impulse. Foundation of trajectory design and mission planning.', variables: ['dv', 'Isp', 'g0', 'm0', 'mf'], solvers: { dv: v => v.Isp * v.g0 * Math.log(v.m0 / v.mf), @@ -170,6 +180,7 @@ export const EQUATIONS = [ name: 'Mass Ratio', formula: 'MR = m₀ / mf', category: 'Rocket Equation', + description: 'Ratio of initial to final mass. Determines the exponential factor in the Tsiolkovsky equation.', variables: ['MR', 'm0', 'mf'], solvers: { MR: v => v.m0 / v.mf, @@ -183,6 +194,7 @@ export const EQUATIONS = [ name: 'Propellant Mass', formula: 'mₚ = m₀ − mf', category: 'Rocket Equation', + description: 'Mass of propellant consumed during burn. Difference between initial (wet) and final (dry) mass.', variables: ['mp', 'm0', 'mf'], solvers: { mp: v => v.m0 - v.mf, @@ -196,6 +208,7 @@ export const EQUATIONS = [ name: 'Propellant Mass Fraction', formula: 'ζ = mₚ / m₀', category: 'Rocket Equation', + description: 'Fraction of total mass that is propellant. Higher values indicate more efficient rocket design.', variables: ['zeta', 'mp', 'm0'], solvers: { zeta: v => v.mp / v.m0, @@ -209,6 +222,7 @@ export const EQUATIONS = [ name: 'Burn Time', formula: 'tᵦ = mₚ / ṁ', category: 'Rocket Equation', + description: 'Duration of main engine burn. Calculated from total propellant mass and mass flow rate.', variables: ['tb', 'mp', 'mdot'], solvers: { tb: v => v.mp / v.mdot, @@ -223,6 +237,7 @@ export const EQUATIONS = [ name: 'Isentropic Temperature Ratio', formula: 'T/T₀ = (1 + (γ−1)/2·M²)⁻¹', category: 'Isentropic Flow', + description: 'Static to stagnation temperature ratio for isentropic flow. Key relation for compressible flow analysis.', variables: ['T', 'T0', 'M', 'gamma'], solvers: { T: v => v.T0 / (1 + (v.gamma - 1) / 2 * v.M * v.M), @@ -241,6 +256,7 @@ export const EQUATIONS = [ name: 'Isentropic Pressure Ratio', formula: 'p/p₀ = (1 + (γ−1)/2·M²)^(−γ/(γ−1))', category: 'Isentropic Flow', + description: 'Static to stagnation pressure ratio for isentropic flow. Essential for nozzle and intake analysis.', variables: ['p_static', 'p0', 'M', 'gamma'], solvers: { p_static: v => { @@ -265,6 +281,7 @@ export const EQUATIONS = [ name: 'Isentropic Density Ratio', formula: 'ρ/ρ₀ = (1 + (γ−1)/2·M²)^(−1/(γ−1))', category: 'Isentropic Flow', + description: 'Static to stagnation density ratio for isentropic flow. Used in continuity equations and flow analysis.', variables: ['rho', 'rho0', 'M', 'gamma'], solvers: { rho: v => { @@ -287,6 +304,7 @@ export const EQUATIONS = [ name: 'Speed of Sound', formula: 'a = √(γ·R·T)', category: 'Isentropic Flow', + description: 'Local speed of sound as a function of temperature and gas properties. Used to define Mach number.', variables: ['a_sound', 'gamma', 'R', 'T'], solvers: { a_sound: v => Math.sqrt(v.gamma * v.R * v.T), @@ -301,6 +319,7 @@ export const EQUATIONS = [ name: 'Flow Velocity', formula: 'v = M·a', category: 'Isentropic Flow', + description: 'Flow velocity as Mach number times local speed of sound. Relates Mach number to actual velocity.', variables: ['v_flow', 'M', 'a_sound'], solvers: { v_flow: v => v.M * v.a_sound, @@ -315,6 +334,7 @@ export const EQUATIONS = [ name: 'Nozzle Expansion Ratio', formula: 'ε = Aₑ / Aₜ', category: 'Nozzle Geometry', + description: 'Ratio of exit area to throat area. Determines the degree of expansion in the nozzle divergent section.', variables: ['eps', 'Ae', 'At'], solvers: { eps: v => v.Ae / v.At, @@ -328,6 +348,7 @@ export const EQUATIONS = [ name: 'Isentropic Area Ratio (supersonic)', formula: 'ε = (1/Mₑ)·[(2/(γ+1))·(1+(γ−1)/2·Mₑ²)]^((γ+1)/(2(γ−1)))', category: 'Nozzle Geometry', + description: 'Relates exit area ratio to exit Mach number for isentropic supersonic flow. Critical for nozzle design.', variables: ['eps', 'Me', 'gamma'], solvers: { eps: v => areaRatioFromMach(v.Me, v.gamma), @@ -340,6 +361,7 @@ export const EQUATIONS = [ name: 'Choked Throat Mass Flow', formula: 'ṁ = Aₜ·p₀·√(γ/(R·T₀))·(2/(γ+1))^((γ+1)/(2(γ−1)))', category: 'Nozzle Geometry', + description: 'Mass flow through a choked throat condition. Maximum mass flow for given stagnation conditions.', variables: ['mdot', 'At', 'p0', 'gamma', 'R', 'T0'], solvers: { mdot: v => { @@ -380,6 +402,7 @@ export const EQUATIONS = [ name: 'Nozzle Exit Pressure', formula: 'pₑ = p₀·(1 + (γ−1)/2·Mₑ²)^(−γ/(γ−1))', category: 'Nozzle Geometry', + description: 'Static pressure at the nozzle exit. Calculated from chamber pressure and exit Mach number.', variables: ['pe', 'p0', 'Me', 'gamma'], solvers: { pe: v => { @@ -402,6 +425,7 @@ export const EQUATIONS = [ name: 'Nozzle Exit Temperature', formula: 'Tₑ = T₀ / (1 + (γ−1)/2·Mₑ²)', category: 'Nozzle Geometry', + description: 'Static temperature at the nozzle exit. Lower than stagnation temperature due to expansion.', variables: ['Te', 'T0', 'Me', 'gamma'], solvers: { Te: v => v.T0 / (1 + (v.gamma - 1) / 2 * v.Me * v.Me), @@ -416,6 +440,7 @@ export const EQUATIONS = [ name: 'Nozzle Exit Velocity', formula: 'Vₑ = Mₑ·√(γ·R·Tₑ)', category: 'Nozzle Geometry', + description: 'Actual exhaust velocity at nozzle exit. Product of exit Mach number and local speed of sound.', variables: ['Ve', 'Me', 'gamma', 'R', 'Te'], solvers: { Ve: v => v.Me * Math.sqrt(v.gamma * v.R * v.Te), @@ -432,6 +457,7 @@ export const EQUATIONS = [ name: 'Thrust-to-Weight Ratio', formula: 'TWR = F / (mᵥ·g₀)', category: 'Performance', + description: 'Ratio of thrust to vehicle weight. Values > 1 allow acceleration and ascent.', variables: ['TWR', 'F', 'm_vehicle', 'g0'], solvers: { TWR: v => v.F / (v.m_vehicle * v.g0), @@ -446,6 +472,7 @@ export const EQUATIONS = [ name: 'Total Impulse', formula: 'J = F·tᵦ', category: 'Performance', + description: 'Total impulse as integrated thrust over burn time. Used for classification and performance assessment.', variables: ['J', 'F', 'tb'], solvers: { J: v => v.F * v.tb, @@ -459,6 +486,7 @@ export const EQUATIONS = [ name: 'Isp from Total Impulse', formula: 'Isp = J / (mₚ·g₀)', category: 'Performance', + description: 'Calculates specific impulse from total impulse and propellant mass. Alternative method for performance analysis.', variables: ['Isp', 'J', 'mp', 'g0'], solvers: { Isp: v => v.J / (v.mp * v.g0), @@ -474,6 +502,7 @@ export const EQUATIONS = [ name: 'Oxidiser/Fuel Ratio', formula: 'O/F = ṁₒₓ / ṁf', category: 'Propellant', + description: 'Mass flow ratio of oxidiser to fuel. Optimum value depends on propellant pair and design parameters.', variables: ['OF', 'mdot_ox', 'mdot_f'], solvers: { OF: v => v.mdot_ox / v.mdot_f, @@ -487,6 +516,7 @@ export const EQUATIONS = [ name: 'Total Mass Flow', formula: 'ṁ = ṁₒₓ + ṁf', category: 'Propellant', + description: 'Total propellant mass flow is sum of oxidiser and fuel mass flows. Used in bipropellant engines.', variables: ['mdot', 'mdot_ox', 'mdot_f'], solvers: { mdot: v => v.mdot_ox + v.mdot_f, @@ -500,12 +530,70 @@ export const EQUATIONS = [ name: 'Mass Flow Split from O/F', formula: 'ṁ_f = ṁ/(1+OF), ṁ_ox = ṁ·OF/(1+OF)', category: 'Propellant', + description: 'Splits total mass flow into oxidiser and fuel flows given their mass ratio. Used for injection design.', variables: ['mdot', 'OF', 'mdot_f', 'mdot_ox'], solvers: { mdot_f: Object.assign(v => v.mdot / (1 + v.OF), { requires: () => ['mdot', 'OF'] }), mdot_ox: Object.assign(v => v.mdot * v.OF / (1 + v.OF), { requires: () => ['mdot', 'OF'] }), }, }, + + // ── Chamber Characteristic Length ────────────────────────────────────── + { + id: 'chamber_characteristic_length', + name: 'Chamber Characteristic Length', + formula: 'L* = Vᶜ / Aₜ', + category: 'Nozzle Performance', + description: 'Characteristic chamber length relates chamber volume to throat area. Typical range 0.5–1.5 m for liquid rockets.', + variables: ['Lstar', 'Vc', 'At'], + solvers: { + Lstar: v => v.Vc / v.At, + Vc: v => v.Lstar * v.At, + At: v => v.Vc / v.Lstar, + }, + }, + + // ── Specific Gas Constant from Molar Mass ───────────────────────────── + { + id: 'specific_gas_constant', + name: 'Specific Gas Constant', + formula: 'R = R̄ / Mₘ', + category: 'Isentropic Flow', + description: 'Specific gas constant for a particular gas, derived from the universal gas constant and molar mass. R̄ = 8.314 J/(mol·K).', + variables: ['R', 'Mm'], + solvers: { + R: v => 8.314 / v.Mm, + Mm: v => 8.314 / v.R, + }, + }, + + // ── Vehicle Mass Identity ───────────────────────────────────────────── + { + id: 'vehicle_mass_identity', + name: 'Vehicle Mass (Wet Mass)', + formula: 'mᵥ = m₀', + category: 'Performance', + description: 'Identity relation: vehicle mass equals initial (wet) mass for TWR calculation.', + variables: ['m_vehicle', 'm0'], + solvers: { + m_vehicle: v => v.m0, + m0: v => v.m_vehicle, + }, + }, + + // ── Subsonic Area Ratio ─────────────────────────────────────────────── + { + id: 'area_ratio_mach_subsonic', + name: 'Isentropic Area Ratio (subsonic)', + formula: 'ε = (1/Mₑ)·[(2/(γ+1))·(1+(γ−1)/2·Mₑ²)]^((γ+1)/(2(γ−1)))', + category: 'Nozzle Geometry', + description: 'Isentropic area ratio as a function of Mach number (subsonic branch). Used for intake and subsonic flow analysis.', + variables: ['eps', 'Me', 'gamma'], + solvers: { + eps: v => areaRatioFromMach(v.Me, v.gamma), + Me: v => machFromAreaRatio(v.eps, v.gamma, false), + }, + }, ] // Equation presets: named groups that seed the workspace with a useful set of variables diff --git a/src/engine/units.js b/src/engine/units.js index 264cae2..c59894f 100644 --- a/src/engine/units.js +++ b/src/engine/units.js @@ -115,6 +115,12 @@ export const UNIT_FAMILIES = { { id: '—', label: '—', toSI: v => v, fromSI: v => v }, ], }, + molar_mass: { + units: [ + { id: 'g/mol', label: 'g/mol', toSI: v => v * 0.001, fromSI: v => v * 1000 }, + { id: 'kg/mol', label: 'kg/mol', toSI: v => v, fromSI: v => v }, + ], + }, } export function getUnitsForFamily(familyId) { diff --git a/src/engine/variables.js b/src/engine/variables.js index f726566..f0bda50 100644 --- a/src/engine/variables.js +++ b/src/engine/variables.js @@ -92,6 +92,20 @@ export const VARIABLES = { unitFamily: 'area', }, + Lstar: { + id: 'Lstar', symbol: 'L*', name: 'Characteristic Chamber Length', units: 'm', + description: 'Characteristic length of the combustion chamber: L* = Vᶜ/Aₜ. Higher values indicate larger chambers.', + category: 'Nozzle Performance', + unitFamily: 'length', + }, + + Vc: { + id: 'Vc', symbol: 'Vᶜ', name: 'Chamber Volume', units: 'm³', + description: 'Total volume of the combustion chamber', + category: 'Nozzle Performance', + unitFamily: 'volume', + }, + // ── Tsiolkovsky Rocket Equation ─────────────────────────────────────── dv: { id: 'dv', symbol: 'Δv', name: 'Delta-v', units: 'm/s', @@ -155,6 +169,13 @@ export const VARIABLES = { category: 'Isentropic Flow', unitFamily: 'spec_gas', }, + + Mm: { + id: 'Mm', symbol: 'Mₘ', name: 'Molar Mass', units: 'kg/mol', + description: 'Molar mass of the exhaust gas mixture. Used to calculate specific gas constant: R = R̄/Mₘ', + category: 'Isentropic Flow', + unitFamily: 'molar_mass', + }, T: { id: 'T', symbol: 'T', name: 'Static Temperature', units: 'K', description: 'Local static temperature at a cross-section', diff --git a/src/hooks/useEngineDesign.js b/src/hooks/useEngineDesign.js index d436a34..f1fc7da 100644 --- a/src/hooks/useEngineDesign.js +++ b/src/hooks/useEngineDesign.js @@ -19,7 +19,7 @@ export function useEngineDesign() { const [chamber, setChamber] = useState({ Lstar: 1.0, contractionRatio: 8, convAngleDeg: 30 }) const [nozzle, setNozzle] = useState({ type: 'conical', divAngleDeg: 15 }) const [injector, setInjector] = useState({ type: 'doublet', N: 20, dpFraction: 0.2, Cd: 0.7, rhoFuel: 800, rhoOx: 1140 }) - const [cooling, setCooling] = useState({ method: 'regenerative', channelCount: 40, filmFraction: 0.05 }) + const [cooling, setCooling] = useState({ method: 'regenerative', channelCount: 60, mu: 6e-5, cp: 2000, Pr: 0.7, T_wall: 800, filmFraction: 0.05 }) const [feedSystem, setFeedSystem] = useState({ type: 'pressure_fed', feedFactor: 1.3, rhoFuel: 800, rhoOx: 1140, pressurantR: 2077, pressurantT: 300, diff --git a/src/pages/EnginePage.jsx b/src/pages/EnginePage.jsx index 9b0cecf..489bcec 100644 --- a/src/pages/EnginePage.jsx +++ b/src/pages/EnginePage.jsx @@ -9,6 +9,7 @@ import { } from '../engine/engineExportImport.js' import DesignSection from '../components/engine/DesignSection.jsx' import EngineModel3D from '../components/engine/EngineModel3D.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' @@ -524,13 +525,48 @@ export default function EnginePage() { infoKey="coolingMethod" /> {cooling.method === 'regenerative' && ( - setCooling(c => ({ ...c, channelCount: Math.max(1, Math.round(v)) }))} - infoKey="channelCount" - step="1" - /> + <> + 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' && ( - + + + {/* ── Right: Results ── */} @@ -647,7 +685,7 @@ export default function EnginePage() { - + )} {cr.method === 'film' && ( diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index 3b738f7..153bb2c 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -23,9 +23,12 @@ export default function Home() {

Equation Solver

-

+

Drag-and-drop rocketry variables. Enter known values and unknowns solve automatically using constraint propagation.

+

+ Includes: thrust, Isp, c*, thrust coefficient, Tsiolkovsky, mass/burn-time, isentropic flow, nozzle geometry, exit conditions, TWR, total impulse, O/F ratio +

Open solver →
@@ -43,14 +46,33 @@ export default function Home() {
Open designer →
-
-
📊
-

Trajectory Plotter

-

- Simulate flight trajectories with drag and gravity losses. + +

🛸
+

+ Rocket Designer +

+

+ Design and configure multi-stage rockets. Visualize your design in 3D with mass budget and delta-v calculations.

-
Coming soon
-
+
Open designer →
+ + + +
📚
+

+ Fuels & Oxidisers +

+

+ Browse a comprehensive database of rocket propellants with chemical properties, performance data, and specifications. +

+
View database →
+ diff --git a/src/pages/KnowledgebaseEquationsPage.jsx b/src/pages/KnowledgebaseEquationsPage.jsx new file mode 100644 index 0000000..d74aeeb --- /dev/null +++ b/src/pages/KnowledgebaseEquationsPage.jsx @@ -0,0 +1,225 @@ +import { useState, useMemo, useRef } from 'react' +import React from 'react' +import { EQUATIONS } from '../engine/equations.js' +import { VARIABLES } from '../engine/variables.js' + +export default function KnowledgebaseEquationsPage() { + const [search, setSearch] = useState('') + const [categoryFilter, setCategoryFilter] = useState('All') + const [activeVariableFilter, setActiveVariableFilter] = useState([]) + const equationRefs = useRef({}) + + // Get all unique categories + const categories = ['All', ...new Set(EQUATIONS.map(eq => eq.category))] + + // Apply all three filters + const filteredEquations = useMemo(() => { + const q = search.toLowerCase().trim() + + return EQUATIONS.filter(eq => { + // Category filter + if (categoryFilter !== 'All' && eq.category !== categoryFilter) return false + + // Text search + if (q) { + const nameMatch = eq.name.toLowerCase().includes(q) + const formulaMatch = eq.formula.toLowerCase().includes(q) + const descMatch = eq.description.toLowerCase().includes(q) + const varMatch = eq.variables.some(varId => { + const v = VARIABLES[varId] + return v?.name.toLowerCase().includes(q) || v?.symbol?.toLowerCase().includes(q) + }) + if (!nameMatch && !formulaMatch && !descMatch && !varMatch) return false + } + + // Variable filter — equation must have ALL active variables + if (activeVariableFilter.length > 0) { + const hasAllVars = activeVariableFilter.every(varId => eq.variables.includes(varId)) + if (!hasAllVars) return false + } + + return true + }) + }, [search, categoryFilter, activeVariableFilter]) + + // Toggle variable in active filter + const toggleVariableFilter = (varId) => { + setActiveVariableFilter(prev => + prev.includes(varId) + ? prev.filter(v => v !== varId) + : [...prev, varId] + ) + } + + // Remove variable from filter + const removeVariableFilter = (varId) => { + setActiveVariableFilter(prev => prev.filter(v => v !== varId)) + } + + // Scroll to equation + const scrollToEquation = (eqId) => { + equationRefs.current[eqId]?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + + return ( +
+ {/* ── Left panel ────────────────────────────────────────────────────── */} +
+ {/* Search */} +
+ setSearch(e.target.value)} + className="w-full px-3 py-1.5 rounded-md bg-slate-800 border border-slate-600 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-blue-500" + /> +
+ + {/* Category filter pills */} +
+ {categories.map(cat => ( + + ))} +
+ + {/* Equation list */} +
+ {filteredEquations.length === 0 && ( +

No equations match your filters.

+ )} + {filteredEquations.map(eq => ( + + ))} +
+
+ + {/* ── Right panel ───────────────────────────────────────────────────── */} +
+ {/* Active variable filter strip */} + {activeVariableFilter.length > 0 && ( +
+ Active filters: + {activeVariableFilter.map(varId => { + const v = VARIABLES[varId] + return ( +
+ {v?.symbol} · {v?.name} + +
+ ) + })} + +
+ )} + + {/* Equations grid */} +
+
+ {filteredEquations.length === 0 ? ( +
+ No equations match your filters. +
+ ) : ( + filteredEquations.map(eq => ( + equationRefs.current[eq.id] = el} + /> + )) + )} +
+
+
+
+ ) +} + +const EquationCard = React.forwardRef(function EquationCard({ eq, activeVariableFilter, onToggleVariable }, ref) { + return ( +
+ {/* Card header */} +
+
+
+ {eq.name} + + {eq.category} + +
+
+
+ + {/* Formula */} +
+
+ {eq.formula} +
+
+ + {/* Description */} +
+

{eq.description}

+
+ + {/* Variable chips */} +
+
+ {eq.variables.map(varId => { + const v = VARIABLES[varId] + const isActive = activeVariableFilter.includes(varId) + return ( + + ) + })} +
+
+
+ ) +}) diff --git a/src/pages/RocketPage.jsx b/src/pages/RocketPage.jsx index e672eb1..7295559 100644 --- a/src/pages/RocketPage.jsx +++ b/src/pages/RocketPage.jsx @@ -3,18 +3,30 @@ import { PropellantModal } from '../components/PropellantModal.jsx' import { useRocketDesign } from '../hooks/useRocketDesign.js' import DesignSection from '../components/engine/DesignSection.jsx' import RocketModel3D from '../components/rocket/RocketModel3D.jsx' +import ErrorBoundary from '../components/ErrorBoundary.jsx' import { formatValue } from '../engine/format.js' +import { getUnitsForFamily } from '../engine/units.js' import { exportRocketJSON, parseRocketImport, downloadBlob } from '../engine/rocketExportImport.js' /* ── Tiny input / result primitives (self-contained, no unit dropdown) ── */ -function NumInput({ label, value, onChange, units, step, placeholder }) { - const display = value == null ? '' : value +function NumInput({ label, value, onChange, units, step, placeholder, unitFamily, defaultUnitId }) { + const unitList = unitFamily ? getUnitsForFamily(unitFamily) : null + const [selectedUnitId, setSelectedUnitId] = useState( + () => defaultUnitId ?? unitList?.[0]?.id ?? 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 n = parseFloat(str) - if (!isNaN(n)) onChange(n) + const typed = parseFloat(str) + if (isNaN(typed)) return + onChange(selectedUnit ? selectedUnit.toSI(typed) : typed) } return ( @@ -22,14 +34,27 @@ function NumInput({ label, value, onChange, units, step, placeholder }) { 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" /> - {units && {units}} + {unitList && unitList.length > 1 ? ( + + ) : unitList?.[0] ? ( + {unitList[0].label} + ) : units ? ( + {units} + ) : null} ) } @@ -235,10 +260,11 @@ export default function RocketPage() { setOuterRadius(v != null ? v / 2000 : null)} - units="mm" - step="10" + value={displayRadius != null ? displayRadius * 2 : null} + onChange={v => setOuterRadius(v != null ? v / 2 : null)} + unitFamily="length" + defaultUnitId="mm" + step="0.01" placeholder={suggestedRadius ? `${(suggestedRadius * 2000).toFixed(0)}` : 'e.g. 300'} /> {suggestedRadius && !outerRadius && ( @@ -292,7 +318,8 @@ export default function RocketPage() { label="Fuel Density (ρ_f)" value={propDensities.rhoFuel} onChange={v => setPropDensities(p => ({ ...p, rhoFuel: v ?? 800 }))} - units="kg/m³" + unitFamily="density" + defaultUnitId="kg/m³" step="10" placeholder="800" /> @@ -300,7 +327,8 @@ export default function RocketPage() { label="Oxidizer Density (ρ_ox)" value={propDensities.rhoOx} onChange={v => setPropDensities(p => ({ ...p, rhoOx: v ?? 1140 }))} - units="kg/m³" + unitFamily="density" + defaultUnitId="kg/m³" step="10" placeholder="1140" /> @@ -308,8 +336,9 @@ export default function RocketPage() { label="Burn Time" value={structure.burnTime} onChange={v => setStructure(s => ({ ...s, burnTime: v }))} - units="s" - step="1" + unitFamily="time" + defaultUnitId="s" + step="0.1" placeholder={engineData?.inputs?.burnTime ?? '30'} /> {effectiveBurnTime != null && !structure.burnTime && ( @@ -324,16 +353,18 @@ export default function RocketPage() { label="Payload Mass" value={payload.mass} onChange={v => setPayload(p => ({ ...p, mass: v ?? 0 }))} - units="kg" + unitFamily="mass" + defaultUnitId="kg" step="1" placeholder="0" /> setPayload(p => ({ ...p, bayLength: v != null ? v / 1000 : 0 }))} - units="mm" - step="10" + value={payload.bayLength} + onChange={v => setPayload(p => ({ ...p, bayLength: v ?? 0 }))} + unitFamily="length" + defaultUnitId="mm" + step="0.001" placeholder="0" /> @@ -352,7 +383,9 @@ export default function RocketPage() { {/* ── Centre: 3D Model ── */}
- + + + {/* Requirements checklist — shown when model can't render */} {!geometry && engineData && (