Skip to main content

rockets_engine/
geometry.rs

1//! Geometry-to-aerodynamics: a Barrowman subset for slender model rockets
2//! (nose cone + body tube + trapezoidal fins), implemented against the
3//! OpenRocket technical documentation and its `FinSetCalc` and
4//! `SymmetricComponentCalc` sources. CP comes from the component moment sum
5//! (with the supersonic aft shift); drag is friction + base + nose wave drag
6//! + rounded-fin form drag. The fin normal-force slope uses Diederich below Mach 1, the Busemann K1 term above Mach 1.5, and OpenRocket's Hermite join between them.
7//!
8//! Induced drag from angle of attack uses the normal-force tilt model
9//! (Cd += Cnα·α²). Not covered (deliberately): boat-tails/transitions,
10//! elliptical or free-form fins, fin-body interference drag, and wave drag
11//! of elliptical/parabolic noses past Mach 1 (conical and ogive are fully
12//! modelled).
13
14use crate::{Error, Result};
15
16/// Nose-cone families supported by the subset.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum NoseShape {
19    /// Straight conical nose.
20    Conical,
21    /// Tangent ogive nose.
22    Ogive,
23    /// Half-ellipsoid nose.
24    Elliptical,
25    /// Power-series parabolic nose (exponent 0.5).
26    Parabolic,
27}
28
29impl std::str::FromStr for NoseShape {
30    type Err = Error;
31
32    fn from_str(s: &str) -> Result<Self> {
33        match s {
34            "conical" | "cone" => Ok(Self::Conical),
35            "ogive" => Ok(Self::Ogive),
36            "elliptical" | "ellipse" => Ok(Self::Elliptical),
37            "parabolic" => Ok(Self::Parabolic),
38            _ => Err(Error::invalid(
39                "nose shape",
40                "conical, ogive, elliptical or parabolic",
41            )),
42        }
43    }
44}
45
46impl NoseShape {
47    /// CP location as a fraction of nose length, from the volume/area relation
48    /// XB = L − V/A_base. Ogive uses the standard tangent-ogive value.
49    fn cp_fraction(self) -> f64 {
50        match self {
51            Self::Conical => 2.0 / 3.0,
52            Self::Ogive => 0.466,
53            Self::Elliptical => 1.0 / 3.0,
54            Self::Parabolic => 0.5,
55        }
56    }
57
58    /// Lateral (wetted) area. Conical uses the exact slant area; the others
59    /// approximate the cylinder at the base diameter.
60    fn lateral_area(self, length_m: f64, diameter_m: f64) -> f64 {
61        let r = diameter_m / 2.0;
62        match self {
63            Self::Conical => std::f64::consts::PI * r * (r * r + length_m * length_m).sqrt(),
64            _ => std::f64::consts::PI * diameter_m * length_m,
65        }
66    }
67}
68
69/// A nose cone with length and base diameter, in metres.
70#[derive(Debug, Clone, Copy)]
71pub struct NoseCone {
72    /// Shape family.
73    pub shape: NoseShape,
74    /// Nose length in metres.
75    pub length_m: f64,
76    /// Base diameter in metres.
77    pub diameter_m: f64,
78}
79
80/// The cylindrical body tube behind the nose.
81#[derive(Debug, Clone, Copy)]
82pub struct BodyTube {
83    /// Tube length in metres.
84    pub length_m: f64,
85    /// Tube diameter in metres.
86    pub diameter_m: f64,
87}
88
89/// Identical trapezoidal fins at the aft end of the tube. `leading_sweep_m` is
90/// the horizontal offset of the tip leading edge from the root leading edge.
91#[derive(Debug, Clone, Copy)]
92pub struct TrapezoidalFins {
93    /// Number of identical fins.
94    pub count: u32,
95    /// Root chord length in metres.
96    pub root_chord_m: f64,
97    /// Tip chord length in metres.
98    pub tip_chord_m: f64,
99    /// Fin semispan in metres (body surface to tip).
100    pub span_m: f64,
101    /// Horizontal offset of the tip leading edge from the root leading edge.
102    pub leading_sweep_m: f64,
103    /// Fin thickness in metres.
104    pub thickness_m: f64,
105}
106
107/// The complete slender geometry.
108/// A conical boat-tail (diameter-reducing transition) at the very aft end
109/// of the body tube.
110#[derive(Debug, Clone, Copy)]
111pub struct BoatTail {
112    /// Transition length in metres.
113    pub length_m: f64,
114    /// Diameter at the forward end (must equal the body tube diameter).
115    pub fore_diameter_m: f64,
116    /// Diameter at the aft end (must be smaller than the forward diameter).
117    pub aft_diameter_m: f64,
118}
119
120/// A slender rocket: nose cone, body tube, trapezoidal fin set and an
121/// optional conical boat-tail.
122#[derive(Debug, Clone, Copy)]
123pub struct RocketGeometry {
124    /// Nose cone.
125    pub nose: NoseCone,
126    /// Cylindrical body tube.
127    pub tube: BodyTube,
128    /// Aft trapezoidal fin set.
129    pub fins: TrapezoidalFins,
130    /// Optional conical boat-tail aft of the fins.
131    pub boat_tail: Option<BoatTail>,
132    /// Roughness height in metres (default 20 µm, painted surface).
133    pub roughness_m: f64,
134}
135
136/// NASA TR-R-100 zero-lift pressure drag for fineness-3 noses (from
137/// OpenRocket's SymmetricComponentCalc), extrapolated to other fineness
138/// ratios by the stagnation-scaled power law.
139const ELLIPSOID_TABLE: &[(f64, f64)] = &[
140    (1.2, 0.110),
141    (1.25, 0.128),
142    (1.3, 0.140),
143    (1.4, 0.148),
144    (1.6, 0.152),
145    (2.0, 0.159),
146    (2.4, 0.162),
147];
148const PARABOLIC_TABLE: &[(f64, f64)] = &[
149    (0.95, 0.0),
150    (0.975, 0.016),
151    (1.0, 0.041),
152    (1.05, 0.092),
153    (1.1, 0.109),
154    (1.2, 0.119),
155    (1.4, 0.113),
156    (1.7, 0.108),
157];
158
159impl RocketGeometry {
160    /// Build with the default painted-surface roughness (20 µm).
161    pub fn new(nose: NoseCone, tube: BodyTube, fins: TrapezoidalFins) -> Self {
162        Self {
163            nose,
164            tube,
165            fins,
166            boat_tail: None,
167            roughness_m: 20e-6,
168        }
169    }
170
171    /// Set the roughness height in metres.
172    pub fn with_roughness(mut self, roughness_m: f64) -> Self {
173        self.roughness_m = roughness_m;
174        self
175    }
176
177    /// Attach a conical boat-tail at the aft end of the tube. Its forward
178    /// diameter must match the tube and its aft diameter must be smaller.
179    pub fn with_boat_tail(mut self, boat_tail: BoatTail) -> Result<Self> {
180        if boat_tail.length_m <= 0.0 || !boat_tail.length_m.is_finite() {
181            return Err(Error::invalid("boat tail", "length must be positive"));
182        }
183        if (boat_tail.fore_diameter_m - self.tube.diameter_m).abs() > 1e-12 {
184            return Err(Error::invalid(
185                "boat tail",
186                "forward diameter must equal the body tube diameter",
187            ));
188        }
189        if boat_tail.aft_diameter_m <= 0.0 || boat_tail.aft_diameter_m >= boat_tail.fore_diameter_m
190        {
191            return Err(Error::invalid(
192                "boat tail",
193                "aft diameter must be positive and smaller than the forward diameter",
194            ));
195        }
196        self.boat_tail = Some(boat_tail);
197        Ok(self)
198    }
199
200    /// Total aerodynamic length (nose + tube + boat-tail).
201    pub fn total_length_m(&self) -> f64 {
202        self.nose.length_m + self.tube.length_m + self.boat_tail.map(|b| b.length_m).unwrap_or(0.0)
203    }
204
205    /// Largest body diameter (nose base or tube).
206    pub fn max_diameter_m(&self) -> f64 {
207        self.nose.diameter_m.max(self.tube.diameter_m)
208    }
209
210    /// Reference area, π/4·d² of the largest diameter.
211    pub fn ref_area_m2(&self) -> f64 {
212        std::f64::consts::FRAC_PI_4 * self.max_diameter_m().powi(2)
213    }
214
215    fn fin_area(&self) -> f64 {
216        self.fins.span_m * (self.fins.root_chord_m + self.fins.tip_chord_m) / 2.0
217    }
218
219    /// Mean aerodynamic chord of one fin (trapezoid formula).
220    fn mac(&self) -> f64 {
221        let (cr, ct) = (self.fins.root_chord_m, self.fins.tip_chord_m);
222        (2.0 / 3.0) * (cr + ct - cr * ct / (cr + ct))
223    }
224
225    /// Fin CP as a fraction of the MAC: quarter-chord at M ≤ 0.5, the
226    /// empirical supersonic formula at M ≥ 2, a linear join between
227    /// (OpenRocket's FinSetCalc.calculateCPPos; it fits a polynomial, but
228    /// the join error is small beside the component moment sum).
229    fn fin_cp_fraction(&self, mach: f64) -> f64 {
230        if mach <= 0.5 {
231            return 0.25;
232        }
233        let ar = 2.0 * self.fins.span_m.powi(2) / self.fin_area();
234        let supersonic = |m: f64| {
235            let beta = (m * m - 1.0).sqrt();
236            (ar * beta - 0.67) / (2.0 * ar * beta - 1.0)
237        };
238        if mach >= 2.0 {
239            return supersonic(mach);
240        }
241        let hi = supersonic(2.0);
242        0.25 + (hi - 0.25) * (mach - 0.5) / 1.5
243    }
244
245    /// Fin CP measured from the fin's root leading edge.
246    fn fin_cp(&self, mach: f64) -> f64 {
247        let (cr, ct, s, m) = (
248            self.fins.root_chord_m,
249            self.fins.tip_chord_m,
250            self.fins.span_m,
251            self.fins.leading_sweep_m,
252        );
253        let y_mac = (s / 3.0) * (cr + 2.0 * ct) / (cr + ct);
254        let x_mac_le = m * y_mac / s;
255        x_mac_le + self.fin_cp_fraction(mach) * self.mac()
256    }
257
258    /// Cosine of the leading-edge sweep angle.
259    fn leading_edge_cos(&self) -> f64 {
260        let (s, m) = (self.fins.span_m, self.fins.leading_sweep_m);
261        s / (s * s + m * m).sqrt()
262    }
263
264    /// Fin pressure (form) drag: OpenRocket's rounded-section model —
265    /// a Mach-dependent leading-edge term times cos²(Γ_LE), plus half the
266    /// base drag for the trailing edge, scaled by span·thickness / A_ref.
267    fn fin_pressure_cd(&self, mach: f64) -> f64 {
268        let span = self.fins.span_m;
269        let thickness = self.fins.thickness_m;
270        if span <= 0.0 || thickness <= 0.0 {
271            return 0.0;
272        }
273        let leading_edge = if mach < 0.9 {
274            (1.0 - mach * mach).powf(-0.417) - 1.0
275        } else if mach < 1.0 {
276            1.0 - 1.785 * (mach - 0.9)
277        } else {
278            let m2 = mach * mach;
279            1.214 - 0.502 / m2 + 0.1095 / (m2 * m2)
280        };
281        let cos_le = self.leading_edge_cos();
282        let cd = leading_edge * cos_le * cos_le + Self::base_cd(mach) / 2.0;
283        // OpenRocket instances the fin set per fin; the set's drag is n
284        // times the single-fin value.
285        cd * span * thickness / self.ref_area_m2() * self.fins.count as f64
286    }
287
288    /// Nose pressure drag per frontal area for conical and tangent-ogive
289    /// noses: OpenRocket's direct calculation (polynomial through M 1–1.3,
290    /// 2.1·sin²φ + 0.5·sinφ/β above, a·M^b + 0.8·sin²φ fitted subsonic).
291    /// Elliptical and parabolic noses are not modelled past Mach 1 yet and
292    /// return 0 there (their subsonic value is 0 in this subset).
293    fn nose_pressure_cd(&self, mach: f64) -> f64 {
294        // sinphi is the sine of the surface angle at 99% of the nose length
295        // (OpenRocket's SymmetricComponentCalc). For a cone that is the half
296        // angle; for a tangent ogive the surface at the base is nearly
297        // axial, so its wave drag is essentially zero — matching OpenRocket.
298        let sinphi = match self.nose.shape {
299            NoseShape::Conical | NoseShape::Ogive => self.nose_sinphi(),
300            NoseShape::Elliptical => return self.table_pressure_cd(mach, ELLIPSOID_TABLE),
301            NoseShape::Parabolic => return self.table_pressure_cd(mach, PARABOLIC_TABLE),
302        };
303        const GAMMA: f64 = 1.4;
304        let cd_mach0 = 0.8 * sinphi * sinphi;
305        let y1 = 2.1 * sinphi * sinphi + 0.6019 * sinphi;
306        let d0 = 4.0 / (GAMMA + 1.0) * (1.0 - 0.5 * sinphi);
307        let d1 = -1.1341 * sinphi;
308        if mach >= 1.3 {
309            return 2.1 * sinphi * sinphi + 0.5 * sinphi / (mach * mach - 1.0).sqrt();
310        }
311        if mach >= 1.0 {
312            return Self::hermite(mach, 1.0, 1.3, sinphi, y1, d0, d1);
313        }
314        // Subsonic: Cd = a·M^b + Cd(0), fitted to reach (1, sinphi) with the
315        // polynomial's derivative (OpenRocket's interpolation scheme).
316        let eps = 0.01;
317        let at_eps = Self::hermite(1.0 + eps, 1.0, 1.3, sinphi, y1, d0, d1);
318        let min_deriv = (at_eps - sinphi) / eps;
319        let gap = sinphi - cd_mach0;
320        if gap <= 0.01 || min_deriv <= 0.01 {
321            return cd_mach0;
322        }
323        let b = min_deriv / gap;
324        gap * mach.powf(b) + cd_mach0
325    }
326
327    /// sin(phi) of the nose surface at 99% of the length (OpenRocket's
328    /// SymmetricComponentCalc convention).
329    fn nose_sinphi(&self) -> f64 {
330        let l = self.nose.length_m;
331        let r = self.nose.diameter_m / 2.0;
332        let r_tip = self.nose_radius(0.99 * l);
333        (r - r_tip) / (r - r_tip).hypot(0.01 * l)
334    }
335
336    /// Stagnation-pressure Cd, OpenRocket's formula.
337    fn stagnation_cd(mach: f64) -> f64 {
338        let m2 = mach * mach;
339        if mach <= 1.0 {
340            0.85 * (1.0 + m2 / 4.0 + m2 * m2 / 40.0)
341        } else {
342            0.85 * (1.84 - 0.76 / m2 + 0.166 / (m2 * m2) + 0.035 / (m2 * m2 * m2))
343        }
344    }
345
346    /// Linear interpolation in a (mach, cd) table, clamped at the ends.
347    fn table_interp(table: &[(f64, f64)], mach: f64) -> f64 {
348        if mach <= table[0].0 {
349            return table[0].1;
350        }
351        for pair in table.windows(2) {
352            let (lo, hi) = (pair[0], pair[1]);
353            if mach <= hi.0 {
354                let f = (mach - lo.0) / (hi.0 - lo.0);
355                return lo.1 + f * (hi.1 - lo.1);
356            }
357        }
358        table[table.len() - 1].1
359    }
360
361    /// Nose pressure drag from the NASA TR-R-100 fineness-3 tables
362    /// (OpenRocket's SymmetricComponentCalc), extrapolated to this nose's
363    /// fineness ratio and joined subsonically by the a·M^b + Cd(0) fit.
364    fn table_pressure_cd(&self, mach: f64, table: &[(f64, f64)]) -> f64 {
365        let fineness = self.nose.length_m / self.nose.diameter_m;
366        let log4 = (fineness + 1.0).ln() / 4.0_f64.ln();
367        let value_at = |m: f64| {
368            let stag = Self::stagnation_cd(m);
369            stag * (Self::table_interp(table, m) / stag).powf(log4)
370        };
371        let min = table[0].0;
372        if mach >= min {
373            return value_at(mach);
374        }
375        let cd_mach0 = 0.8 * self.nose_sinphi().powi(2);
376        let min_value = value_at(min);
377        if min_value < 0.001 {
378            return cd_mach0;
379        }
380        let min_deriv = (value_at(min + 0.01) - min_value) / 0.01;
381        let gap = min_value - cd_mach0;
382        if gap <= 0.01 || min_deriv <= 0.01 {
383            return cd_mach0;
384        }
385        let b = min * min_deriv / gap;
386        let a = gap / min.powf(b);
387        a * mach.powf(b) + cd_mach0
388    }
389
390    fn midchord_cos(&self) -> f64 {
391        let (cr, ct, s, m) = (
392            self.fins.root_chord_m,
393            self.fins.tip_chord_m,
394            self.fins.span_m,
395            self.fins.leading_sweep_m,
396        );
397        let gamma = ((m + ct / 2.0 - cr / 2.0) / s).atan();
398        gamma.cos()
399    }
400
401    /// Cubic Hermite interpolation on [x0, x1] with endpoint values y0, y1
402    /// and endpoint derivatives d0, d1 per unit x.
403    fn hermite(x: f64, x0: f64, x1: f64, y0: f64, y1: f64, d0: f64, d1: f64) -> f64 {
404        let h = x1 - x0;
405        let t = (x - x0) / h;
406        let t2 = t * t;
407        let t3 = t2 * t;
408        (2.0 * t3 - 3.0 * t2 + 1.0) * y0
409            + (t3 - 2.0 * t2 + t) * h * d0
410            + (-2.0 * t3 + 3.0 * t2) * y1
411            + (t3 - t2) * h * d1
412    }
413
414    /// Mach bounds of OpenRocket's transonic interpolation.
415    const CNA_SUBSONIC: f64 = 0.9;
416    const CNA_SUPERSONIC: f64 = 1.5;
417
418    /// Normal-force slope of one fin, divided by the reference area.
419    /// Subsonic: OpenRocket's Diederich simplification. Supersonic:
420    /// 2·A_fin/(β·A_ref) (the K1 term of the Busemann expansion; the α
421    /// terms vanish for a slope table). Transonic: OpenRocket's cubic
422    /// Hermite join with matched endpoint derivatives.
423    fn fin_cna1(&self, mach: f64) -> f64 {
424        let span = self.fins.span_m;
425        let area = self.fin_area();
426        let cos = self.midchord_cos();
427        if area <= 0.0 || span <= 0.0 || cos <= 0.0 {
428            return 0.0;
429        }
430        let ref_area = self.ref_area_m2();
431        let subsonic = |m: f64| {
432            let ratio = (span * span) / (area * cos);
433            2.0 * std::f64::consts::PI * span * span
434                / (1.0 + (1.0 + (1.0 - m * m) * ratio * ratio).sqrt())
435                / ref_area
436        };
437        let supersonic = |m: f64| 2.0 * area / (m * m - 1.0).sqrt() / ref_area;
438        if mach <= Self::CNA_SUBSONIC {
439            return subsonic(mach);
440        }
441        if mach >= Self::CNA_SUPERSONIC {
442            return supersonic(mach);
443        }
444        let sq = (1.0
445            + (1.0 - Self::CNA_SUBSONIC * Self::CNA_SUBSONIC)
446                * (span * span / (area * cos)).powi(2))
447        .sqrt();
448        let sub_v = 2.0 * std::f64::consts::PI * span * span / ref_area / (1.0 + sq);
449        let sub_d = 2.0 * Self::CNA_SUBSONIC * std::f64::consts::PI * span.powi(6)
450            / ((area * cos).powi(2) * ref_area * sq * (1.0 + sq).powi(2));
451        let super_v = supersonic(Self::CNA_SUPERSONIC);
452        let beta_b = (Self::CNA_SUPERSONIC * Self::CNA_SUPERSONIC - 1.0).powf(1.5);
453        let super_d = -area / ref_area * 2.0 * Self::CNA_SUPERSONIC / beta_b;
454        Self::hermite(
455            mach,
456            Self::CNA_SUBSONIC,
457            Self::CNA_SUPERSONIC,
458            sub_v,
459            super_v,
460            sub_d,
461            super_d,
462        )
463    }
464
465    /// Body-interference factor (1 + tau) with tau = r/(span + r).
466    fn interference_tau(&self) -> f64 {
467        let r = self.max_diameter_m() / 2.0;
468        r / (self.fins.span_m + r)
469    }
470
471    fn fin_count_factor(&self) -> f64 {
472        match self.fins.count {
473            5 => 0.948,
474            6 => 0.913,
475            7 => 0.854,
476            8 => 0.810,
477            n if n >= 9 => 0.75,
478            _ => 1.0,
479        }
480    }
481
482    /// Total fin-set normal-force slope for this Mach number.
483    ///
484    /// The Diederich `fin_cna1` is the slope of a fin *pair* (a wing of
485    /// semispan `span`), so a set of `n` fins contributes `n/2` pairs,
486    /// with fin-body carryover (1 + tau) and a many-fin interference
487    /// reduction. This matches OpenRocket's `FinSetCalc` convention,
488    /// verified against its BarrowmanCalculatorTest Alpha III values.
489    fn fin_set_cna(&self, mach: f64) -> f64 {
490        self.fin_cna1(mach)
491            * (1.0 + self.interference_tau())
492            * self.fin_count_factor()
493            * (self.fins.count as f64 / 2.0)
494    }
495
496    /// Fin CP measured from the nose tip (fin trailing edge at tube end).
497    fn fin_cp_abs(&self, mach: f64) -> f64 {
498        self.nose.length_m + self.tube.length_m - self.fins.root_chord_m + self.fin_cp(mach)
499    }
500
501    /// Nose radius profile r(x) with x measured from the tip.
502    fn nose_radius(&self, x: f64) -> f64 {
503        let l = self.nose.length_m;
504        let r = self.nose.diameter_m / 2.0;
505        let t = (x / l).clamp(0.0, 1.0);
506        match self.nose.shape {
507            NoseShape::Conical => r * t,
508            NoseShape::Elliptical => r * (1.0 - (1.0 - t).powi(2)).sqrt(),
509            NoseShape::Parabolic => r * t * (2.0 - t),
510            NoseShape::Ogive => {
511                let rho = (r * r + l * l) / (2.0 * r);
512                (rho * rho - (l - x).powi(2)).max(0.0).sqrt() - (rho - r)
513            }
514        }
515    }
516
517    /// Solid-nose CG (from the tip) and pitch inertia per unit mass about
518    /// that CG, from numerical integration of the radius profile.
519    fn nose_volume_properties(&self) -> (f64, f64) {
520        const N: usize = 1024;
521        let l = self.nose.length_m;
522        let dx = l / N as f64;
523        let mut volume = 0.0; // ∫ r² dx (up to the density·π factor)
524        let mut moment = 0.0; // ∫ x r² dx
525        for i in 0..N {
526            let x = (i as f64 + 0.5) * dx;
527            let r2 = self.nose_radius(x).powi(2);
528            volume += r2 * dx;
529            moment += x * r2 * dx;
530        }
531        let cg = moment / volume;
532        let mut inertia = 0.0; // ∫ r² (r²/4 + (x−cg)²) dx
533        for i in 0..N {
534            let x = (i as f64 + 0.5) * dx;
535            let r2 = self.nose_radius(x).powi(2);
536            inertia += r2 * (r2 / 4.0 + (x - cg).powi(2)) * dx;
537        }
538        (cg, inertia / volume)
539    }
540
541    /// Fin planform centroid, measured from the root leading edge.
542    fn fin_planform_cg(&self) -> f64 {
543        let (cr, ct, m) = (
544            self.fins.root_chord_m,
545            self.fins.tip_chord_m,
546            self.fins.leading_sweep_m,
547        );
548        (cr * cr + cr * ct + ct * ct + m * (cr + 2.0 * ct)) / (3.0 * (cr + ct))
549    }
550
551    /// Fin centroid height above the body surface.
552    fn fin_radial_cg(&self) -> f64 {
553        let (cr, ct, s) = (
554            self.fins.root_chord_m,
555            self.fins.tip_chord_m,
556            self.fins.span_m,
557        );
558        s * (cr + 2.0 * ct) / (3.0 * (cr + ct))
559    }
560
561    /// Combine component masses with the geometry's component CGs into a
562    /// dry CG and a pitch-inertia estimate about that CG.
563    ///
564    /// The nose is a solid of revolution (numerically integrated), the
565    /// tube a thin-walled cylinder, and the fins and optional motor casing
566    /// point masses; evenly spaced fins add n/2 · m · (r_body + y_cg)² for
567    /// their radial offset. `casing` is (mass_kg, CG from the nose tip).
568    pub fn mass_properties(
569        &self,
570        nose_kg: f64,
571        tube_kg: f64,
572        fins_kg: f64,
573        casing: Option<(f64, f64)>,
574    ) -> MassProperties {
575        self.mass_properties_with_boat(nose_kg, tube_kg, fins_kg, None, casing)
576    }
577
578    /// [`mass_properties`](Self::mass_properties) with an optional boat-tail
579    /// shell mass (treated as a thin-walled frustum).
580    pub fn mass_properties_with_boat(
581        &self,
582        nose_kg: f64,
583        tube_kg: f64,
584        fins_kg: f64,
585        boat_kg: Option<f64>,
586        casing: Option<(f64, f64)>,
587    ) -> MassProperties {
588        assert!(
589            nose_kg >= 0.0 && tube_kg >= 0.0 && fins_kg >= 0.0,
590            "component masses must be non-negative"
591        );
592        let (nose_cg, nose_inertia) = self.nose_volume_properties();
593        let tube_cg = self.nose.length_m + self.tube.length_m / 2.0;
594        let fin_cg = self.nose.length_m + self.tube.length_m - self.fins.root_chord_m
595            + self.fin_planform_cg();
596        let mut parts: Vec<(f64, f64, f64)> = vec![
597            (nose_kg, nose_cg, nose_kg * nose_inertia),
598            (
599                tube_kg,
600                tube_cg,
601                tube_kg * (self.tube.length_m.powi(2) / 12.0 + self.tube.diameter_m.powi(2) / 8.0),
602            ),
603            (fins_kg, fin_cg, 0.0),
604        ];
605        if let (Some(b), Some(kg)) = (self.boat_tail, boat_kg) {
606            let rf = b.fore_diameter_m / 2.0;
607            let ra = b.aft_diameter_m / 2.0;
608            // Thin-walled frustum shell: lateral centroid and a mean-radius
609            // thin-shell inertia estimate.
610            let cg = self.nose.length_m
611                + self.tube.length_m
612                + b.length_m * (rf + 2.0 * ra) / (3.0 * (rf + ra));
613            let mean_r = (rf + ra) / 2.0;
614            parts.push((
615                kg,
616                cg,
617                kg * (b.length_m.powi(2) / 12.0 + mean_r * mean_r / 2.0),
618            ));
619        }
620        if let Some((kg, cg)) = casing {
621            parts.push((kg, cg, 0.0));
622        }
623        let total: f64 = parts.iter().map(|p| p.0).sum();
624        assert!(total > 0.0, "component masses must sum to a positive mass");
625        let cg: f64 = parts.iter().map(|p| p.0 * p.1).sum::<f64>() / total;
626        let mut inertia: f64 = parts.iter().map(|p| p.2 + p.0 * (p.1 - cg).powi(2)).sum();
627        inertia += fins_kg * (self.tube.diameter_m / 2.0 + self.fin_radial_cg()).powi(2) / 2.0;
628        MassProperties {
629            dry_mass_kg: total,
630            dry_cg_m: cg,
631            pitch_inertia_kg_m2: inertia,
632        }
633    }
634
635    /// Boat-tail normal-force slope: 2·((r_aft/r_fore)² − 1), negative for a
636    /// diameter-reducing transition (Barrowman).
637    fn boat_cna(&self) -> f64 {
638        match self.boat_tail {
639            Some(b) => {
640                let ratio = b.aft_diameter_m / b.fore_diameter_m;
641                2.0 * (ratio * ratio - 1.0)
642            }
643            None => 0.0,
644        }
645    }
646
647    /// Boat-tail CP from the nose tip: the frustum planform centroid,
648    /// x̄ = L·(r_fore + 3·r_aft)/(4·(r_fore + r_aft)) from its forward end.
649    fn boat_cp_abs(&self) -> f64 {
650        match self.boat_tail {
651            Some(b) => {
652                let rf = b.fore_diameter_m / 2.0;
653                let ra = b.aft_diameter_m / 2.0;
654                self.nose.length_m
655                    + self.tube.length_m
656                    + b.length_m * (rf + 3.0 * ra) / (4.0 * (rf + ra))
657            }
658            None => 0.0,
659        }
660    }
661
662    /// Boat-tail pressure drag (OpenRocket's SymmetricComponentCalc):
663    /// long gentle tapers (fineness ≥ 3) are free; shorter ones pay the
664    /// base drag over the annulus area, scaled by (3 − fineness)/2.
665    fn boat_pressure_cd(&self, mach: f64) -> f64 {
666        let Some(b) = self.boat_tail else {
667            return 0.0;
668        };
669        let fineness = b.length_m / (b.fore_diameter_m - b.aft_diameter_m);
670        if fineness >= 3.0 {
671            return 0.0;
672        }
673        let annulus =
674            std::f64::consts::FRAC_PI_4 * (b.fore_diameter_m.powi(2) - b.aft_diameter_m.powi(2));
675        let cd = Self::base_cd(mach) * annulus / self.ref_area_m2();
676        if fineness <= 1.0 {
677            cd
678        } else {
679            cd * (3.0 - fineness) / 2.0
680        }
681    }
682
683    /// Base-drag scaling: the base area shrinks to the boat-tail's aft face.
684    fn base_area_fraction(&self) -> f64 {
685        match self.boat_tail {
686            Some(b) => (b.aft_diameter_m / self.max_diameter_m()).powi(2),
687            None => 1.0,
688        }
689    }
690
691    /// Total CP from the component moment sum (nose + fin set + boat-tail).
692    fn total_cp(&self, mach: f64) -> f64 {
693        let nose_cna = 2.0;
694        let fin_cna = self.fin_set_cna(mach);
695        let boat_cna = self.boat_cna();
696        let total = nose_cna + fin_cna + boat_cna;
697        if total <= 0.0 {
698            return f64::NAN;
699        }
700        (nose_cna * self.nose.length_m * self.nose.shape.cp_fraction()
701            + fin_cna * self.fin_cp_abs(mach)
702            + boat_cna * self.boat_cp_abs())
703            / total
704    }
705
706    #[cfg(test)]
707    fn nose_cp(&self) -> f64 {
708        self.nose.length_m * self.nose.shape.cp_fraction()
709    }
710
711    fn base_cd(mach: f64) -> f64 {
712        if mach <= 1.0 {
713            0.12 + 0.13 * mach * mach
714        } else {
715            0.25 / mach
716        }
717    }
718
719    fn friction_cd(&self, mach: f64, sound_mps: f64) -> f64 {
720        let length = self.total_length_m();
721        let velocity = mach * sound_mps;
722        let reynolds = velocity * length / 1.5e-5;
723        let rough_ratio = self.roughness_m / length;
724        let cf_turbulent = if reynolds < 1.0e4 {
725            1.48e-2
726        } else {
727            1.0 / (1.5 * reynolds.ln() - 5.6).powi(2)
728        };
729        let cf_rough = 0.032 * rough_ratio.powf(0.2);
730        // Fully turbulent: take the larger of the smooth and roughness-limited
731        // coefficients, then the subsonic compressibility correction.
732        let cf = cf_turbulent.max(cf_rough) * (1.0 - 0.1 * mach * mach);
733        let r = self.max_diameter_m() / 2.0;
734        let fineness = length / r;
735        let correction = 1.0 + 1.0 / (2.0 * fineness);
736        let boat_wet = match self.boat_tail {
737            Some(b) => {
738                let rf = b.fore_diameter_m / 2.0;
739                let ra = b.aft_diameter_m / 2.0;
740                let slant = (b.length_m.powi(2) + (rf - ra).powi(2)).sqrt();
741                std::f64::consts::PI * (rf + ra) * slant
742            }
743            None => 0.0,
744        };
745        let body_wet = self
746            .nose
747            .shape
748            .lateral_area(self.nose.length_m, self.nose.diameter_m)
749            + std::f64::consts::PI * self.tube.diameter_m * self.tube.length_m
750            + boat_wet;
751        let fin_wet = 2.0 * self.fins.count as f64 * self.fin_area();
752        let thickness_factor = 1.0 + 2.0 * self.fins.thickness_m / self.mac();
753        cf * (correction * body_wet + thickness_factor * fin_wet) / self.ref_area_m2()
754    }
755
756    /// Full analysis. Mach samples at 0.0, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0,
757    /// 1.1, 1.3, 1.5, 2.0 and 3.0, computed across the transonic and
758    /// supersonic regimes (no clamp).
759    pub fn analyze(&self) -> AeroAnalysis {
760        self.validate();
761        let machs: [f64; 12] = [0.0, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0, 1.1, 1.3, 1.5, 2.0, 3.0];
762        let mut cd_vs_mach = Vec::new();
763        let mut cna_vs_mach = Vec::new();
764        let mut cp_vs_mach = Vec::new();
765        let mut induced_k_vs_mach = Vec::new();
766        for &m in &machs {
767            cd_vs_mach.push((
768                m,
769                Self::base_cd(m) * self.base_area_fraction()
770                    + self.nose_pressure_cd(m)
771                    + self.fin_pressure_cd(m)
772                    + self.boat_pressure_cd(m)
773                    + self.friction_cd(m, 340.0),
774            ));
775            let fin_cna = self.fin_set_cna(m);
776            let total_cna = 2.0 + fin_cna + self.boat_cna();
777            cna_vs_mach.push((m, total_cna));
778            cp_vs_mach.push((m, self.total_cp(m)));
779            // Induced drag from the normal force's tilt onto the flight
780            // path: Cd_induced = Cnα·α², so k = Cnα (OpenRocket's model).
781            induced_k_vs_mach.push((m, total_cna));
782        }
783        AeroAnalysis {
784            cd_vs_mach,
785            cna_vs_mach,
786            cp_vs_mach,
787            induced_k_vs_mach,
788            ref_area_m2: self.ref_area_m2(),
789            total_length_m: self.total_length_m(),
790            max_diameter_m: self.max_diameter_m(),
791        }
792    }
793
794    fn validate(&self) {
795        assert!(self.nose.length_m > 0.0 && self.tube.length_m > 0.0);
796        assert!(self.nose.diameter_m > 0.0 && self.tube.diameter_m > 0.0);
797        assert!(self.fins.count > 0);
798        assert!(self.fins.span_m > 0.0);
799        assert!(self.fins.root_chord_m > 0.0 && self.fins.tip_chord_m > 0.0);
800        assert!(self.fins.thickness_m > 0.0);
801    }
802}
803
804/// Parse the compact geometry spec used by the CLI:
805/// `nose=shape:length:diameter,tube=length:diameter,fins=count:root:tip:span:sweep:thickness`,
806/// with an optional `boat=length:fore_diameter:aft_diameter` conical boat-tail
807/// at the aft end. The fin thickness is optional (default 2 mm). Lengths are
808/// in metres.
809pub fn parse_geometry(spec: &str) -> Result<RocketGeometry> {
810    let mut nose = None;
811    let mut tube = None;
812    let mut fins = None;
813    let mut boat_tail = None;
814    for item in spec.split(',') {
815        let (key, val) = item
816            .split_once('=')
817            .ok_or_else(|| Error::invalid("geometry", "expected key=value items"))?;
818        let parts: Vec<&str> = val.split(':').collect();
819        let number = |i: usize| -> Result<f64> {
820            parts
821                .get(i)
822                .ok_or_else(|| Error::invalid("geometry", "missing numeric field"))?
823                .parse()
824                .map_err(|_| Error::invalid("geometry", "non-numeric field"))
825        };
826        match key {
827            "nose" => {
828                if parts.len() != 3 {
829                    return Err(Error::invalid("nose", "expected shape:length:diameter"));
830                }
831                let shape = parts[0].parse::<NoseShape>()?;
832                nose = Some(NoseCone {
833                    shape,
834                    length_m: number(1)?,
835                    diameter_m: number(2)?,
836                });
837            }
838            "tube" => {
839                if parts.len() != 2 {
840                    return Err(Error::invalid("tube", "expected length:diameter"));
841                }
842                tube = Some(BodyTube {
843                    length_m: number(0)?,
844                    diameter_m: number(1)?,
845                });
846            }
847            "fins" => {
848                if !(5..=6).contains(&parts.len()) {
849                    return Err(Error::invalid(
850                        "fins",
851                        "expected count:root:tip:span:sweep[:thickness]",
852                    ));
853                }
854                let count: u32 = parts[0]
855                    .parse()
856                    .map_err(|_| Error::invalid("fin count", "must be an integer"))?;
857                fins = Some(TrapezoidalFins {
858                    count,
859                    root_chord_m: number(1)?,
860                    tip_chord_m: number(2)?,
861                    span_m: number(3)?,
862                    leading_sweep_m: number(4)?,
863                    thickness_m: if parts.len() == 6 { number(5)? } else { 0.002 },
864                });
865            }
866            "boat" => {
867                if parts.len() != 3 {
868                    return Err(Error::invalid(
869                        "boat",
870                        "expected boat=length:fore_diameter:aft_diameter",
871                    ));
872                }
873                boat_tail = Some(BoatTail {
874                    length_m: number(0)?,
875                    fore_diameter_m: number(1)?,
876                    aft_diameter_m: number(2)?,
877                });
878            }
879            other => {
880                return Err(Error::invalid(
881                    "geometry key",
882                    match other {
883                        "nose" | "tube" | "fins" => unreachable!(),
884                        _ => "must be nose, tube, fins or boat",
885                    },
886                ))
887            }
888        }
889    }
890    let geometry = RocketGeometry::new(
891        nose.ok_or_else(|| Error::invalid("geometry", "nose is required"))?,
892        tube.ok_or_else(|| Error::invalid("geometry", "tube is required"))?,
893        fins.ok_or_else(|| Error::invalid("geometry", "fins are required"))?,
894    );
895    match boat_tail {
896        Some(boat) => geometry.with_boat_tail(boat),
897        None => Ok(geometry),
898    }
899}
900
901/// Dry-mass breakdown estimated from geometry and component masses by
902/// [`RocketGeometry::mass_properties`].
903#[derive(Debug, Clone, Copy)]
904pub struct MassProperties {
905    /// Sum of the component masses (structure plus motor casing).
906    pub dry_mass_kg: f64,
907    /// Dry centre of gravity, metres aft of the nose tip.
908    pub dry_cg_m: f64,
909    /// Pitch inertia about the dry CG, kg·m².
910    pub pitch_inertia_kg_m2: f64,
911}
912
913/// The curves and reference quantities produced by [`RocketGeometry::analyze`].
914#[derive(Debug, Clone)]
915pub struct AeroAnalysis {
916    /// Axial drag coefficient vs Mach (subsonic, clamped above Mach 1).
917    pub cd_vs_mach: Vec<(f64, f64)>,
918    /// Normal-force coefficient slope per radian vs Mach (nose + fin set).
919    pub cna_vs_mach: Vec<(f64, f64)>,
920    /// Centre of pressure, metres aft of the nose tip, vs Mach.
921    pub cp_vs_mach: Vec<(f64, f64)>,
922    /// Induced-drag factor per radian² vs Mach (normal-force tilt: k = Cnα).
923    pub induced_k_vs_mach: Vec<(f64, f64)>,
924    /// Reference area, π/4·d² of the largest diameter.
925    pub ref_area_m2: f64,
926    /// Total aerodynamic length (nose + tube).
927    pub total_length_m: f64,
928    /// Largest body diameter.
929    pub max_diameter_m: f64,
930}
931
932#[cfg(feature = "ballistics")]
933impl AeroAnalysis {
934    /// Bundle the analysis with the remaining airframe parameters.
935    pub fn airframe(
936        &self,
937        cg_dry_m: f64,
938        cg_prop_m: f64,
939        pitch_inertia_kg_m2: f64,
940        recovery_cd_area_m2: f64,
941    ) -> crate::Airframe {
942        crate::Airframe {
943            diameter_m: self.max_diameter_m,
944            cd_vs_mach: self.cd_vs_mach.clone(),
945            cna_vs_mach: self.cna_vs_mach.clone(),
946            cp_vs_mach: self.cp_vs_mach.clone(),
947            cg_dry_m,
948            cg_prop_m,
949            pitch_inertia_kg_m2,
950            induced_k_vs_mach: self.induced_k_vs_mach.clone(),
951            recovery_cd_area_m2,
952        }
953    }
954
955    /// Drag + atmosphere + body lift for this geometry in the given wind.
956    pub fn forces(&self, wind_mps: crate::Vec3) -> Result<crate::ballistics::BallisticForces> {
957        crate::ballistics::BallisticForces::new(
958            self.max_diameter_m,
959            self.cd_vs_mach.clone(),
960            crate::ballistics::Atmosphere::default(),
961            wind_mps,
962        )?
963        .with_body_lift(self.cna_vs_mach.clone(), self.induced_k_vs_mach.clone())
964    }
965
966    /// Weathercock parameters for this geometry.
967    pub fn weathercock_params(
968        &self,
969        cg_dry_m: f64,
970        cg_prop_m: f64,
971        pitch_inertia_kg_m2: f64,
972    ) -> Result<crate::WeathercockParams> {
973        crate::WeathercockParams::new(
974            self.cna_vs_mach.clone(),
975            self.cp_vs_mach.clone(),
976            cg_dry_m,
977            cg_prop_m,
978            self.max_diameter_m,
979            pitch_inertia_kg_m2,
980            0.2,
981        )
982    }
983}
984
985#[cfg(test)]
986mod tests {
987    use super::*;
988
989    fn example() -> RocketGeometry {
990        RocketGeometry::new(
991            NoseCone {
992                shape: NoseShape::Conical,
993                length_m: 0.1,
994                diameter_m: 0.024,
995            },
996            BodyTube {
997                length_m: 0.4,
998                diameter_m: 0.024,
999            },
1000            TrapezoidalFins {
1001                count: 3,
1002                root_chord_m: 0.05,
1003                tip_chord_m: 0.03,
1004                span_m: 0.04,
1005                leading_sweep_m: 0.02,
1006                thickness_m: 0.002,
1007            },
1008        )
1009    }
1010
1011    #[test]
1012    fn nose_cp_fractions_match_barrowman() {
1013        assert_eq!(NoseShape::Conical.cp_fraction(), 2.0 / 3.0);
1014        assert_eq!(NoseShape::Ogive.cp_fraction(), 0.466);
1015        assert_eq!(NoseShape::Elliptical.cp_fraction(), 1.0 / 3.0);
1016        assert_eq!(NoseShape::Parabolic.cp_fraction(), 0.5);
1017    }
1018
1019    #[test]
1020    fn conical_pressure_drag_uses_half_angle() {
1021        // The M->0 limit of the wave-drag model is Barrowman's 0.8·sin²(φ).
1022        let geo = example(); // conical nose, L = 0.1, R = 0.012
1023        let sin_phi = 0.012 / (0.012_f64.powi(2) + 0.1_f64.powi(2)).sqrt();
1024        assert!((geo.nose_pressure_cd(0.0) - 0.8 * sin_phi * sin_phi).abs() < 1e-12);
1025    }
1026
1027    #[test]
1028    fn nose_wave_drag_matches_openrocket_formula() {
1029        let geo = example(); // conical, L = 0.1, R = 0.012
1030        let r = 0.012_f64;
1031        let sinphi = r / (r * r + 0.1 * 0.1).sqrt();
1032        // Supersonic direct formula: 2.1·sin²φ + 0.5·sinφ/β.
1033        let m = 1.5_f64;
1034        let expected = 2.1 * sinphi * sinphi + 0.5 * sinphi / (m * m - 1.0).sqrt();
1035        assert!(
1036            (geo.nose_pressure_cd(m) - expected).abs() < 1e-12,
1037            "{} vs {expected}",
1038            geo.nose_pressure_cd(m)
1039        );
1040        // Monotone rise through the transonic region, continuous at M = 1.
1041        let below = geo.nose_pressure_cd(0.999);
1042        let at = geo.nose_pressure_cd(1.0);
1043        assert!((at - sinphi).abs() < 0.02, "at {at}");
1044        assert!(below < at && below > 0.8 * sinphi * sinphi);
1045    }
1046
1047    #[test]
1048    fn fin_cp_matches_the_trapezoid_formula() {
1049        let geo = example();
1050        // Independently: MAC = (2/3)(cr+ct - cr*ct/(cr+ct)), yMAC = s(cr+2ct)/(3(cr+ct)).
1051        let mac = (2.0 / 3.0) * (0.05 + 0.03 - 0.05 * 0.03 / 0.08);
1052        let y_mac = (0.04 / 3.0) * (0.05 + 2.0 * 0.03) / 0.08;
1053        let expected = 0.02 * y_mac / 0.04 + 0.25 * mac;
1054        assert!(
1055            (geo.fin_cp(0.0) - expected).abs() < 1e-12,
1056            "{}",
1057            geo.fin_cp(0.0)
1058        );
1059    }
1060
1061    #[test]
1062    fn boat_tail_physics() {
1063        let base = example();
1064        let with_boat = base
1065            .with_boat_tail(BoatTail {
1066                length_m: 0.06,
1067                fore_diameter_m: 0.024,
1068                aft_diameter_m: 0.016,
1069            })
1070            .unwrap();
1071        // Cnα drops by 2·(1 − (2/3)²) = 10/9 ≈ 1.111.
1072        let delta = base.fin_set_cna(0.0) + 2.0 - (with_boat.fin_set_cna(0.0) + 2.0);
1073        let expected = 2.0 * (1.0 - (0.016_f64 / 0.024).powi(2));
1074        assert!((with_boat.boat_cna() + expected).abs() < 1e-12);
1075        assert!((delta - 0.0).abs() < 1e-12 || delta > 0.0); // fins unchanged
1076        let cna_base = base.analyze().cna_vs_mach[0].1;
1077        let cna_boat = with_boat.analyze().cna_vs_mach[0].1;
1078        assert!(
1079            (cna_base - cna_boat - expected).abs() < 1e-9,
1080            "{cna_base} vs {cna_boat}"
1081        );
1082        // CP: frustum centroid at nose+tube + L·(rf+3ra)/(4(rf+ra)).
1083        let cp = with_boat.boat_cp_abs();
1084        let want = 0.1 + 0.4 + 0.06 * (0.012 + 3.0 * 0.008) / (4.0 * (0.012 + 0.008));
1085        assert!((cp - want).abs() < 1e-12, "{cp} vs {want}");
1086        // Pressure drag: fineness = 0.06/0.008 = 7.5 ≥ 3 → zero; short tail pays.
1087        assert_eq!(with_boat.boat_pressure_cd(0.5), 0.0);
1088        let stubby = base
1089            .with_boat_tail(BoatTail {
1090                length_m: 0.012,
1091                fore_diameter_m: 0.024,
1092                aft_diameter_m: 0.016,
1093            })
1094            .unwrap();
1095        assert!(stubby.boat_pressure_cd(0.5) > 0.0);
1096        // Base area shrinks to (aft/fore)².
1097        assert!((with_boat.base_area_fraction() - (0.016_f64 / 0.024).powi(2)).abs() < 1e-12);
1098        // Total length includes the boat-tail; fins stay at the tube end.
1099        assert!((with_boat.total_length_m() - 0.56).abs() < 1e-12);
1100        assert!((with_boat.fin_cp_abs(0.0) - base.fin_cp_abs(0.0)).abs() < 1e-12);
1101        // Validation.
1102        assert!(base
1103            .with_boat_tail(BoatTail {
1104                length_m: 0.06,
1105                fore_diameter_m: 0.03,
1106                aft_diameter_m: 0.016,
1107            })
1108            .is_err());
1109        assert!(base
1110            .with_boat_tail(BoatTail {
1111                length_m: 0.06,
1112                fore_diameter_m: 0.024,
1113                aft_diameter_m: 0.03,
1114            })
1115            .is_err());
1116        // Parse end to end.
1117        let parsed = parse_geometry(
1118            "nose=conical:0.1:0.024,tube=0.4:0.024,fins=3:0.05:0.03:0.04:0.02:0.002,boat=0.06:0.024:0.016",
1119        )
1120        .unwrap();
1121        assert!(parsed.boat_tail.is_some());
1122        assert!(parse_geometry(
1123            "nose=conical:0.1:0.024,tube=0.4:0.024,fins=3:0.05:0.03:0.04:0.02:0.002,boat=0.06:0.024:0.03",
1124        )
1125        .is_err());
1126        // Mass model: the boat shell joins the component sum.
1127        let props = with_boat.mass_properties_with_boat(0.012, 0.020, 0.006, Some(0.004), None);
1128        assert!((props.dry_mass_kg - 0.042).abs() < 1e-12);
1129        assert!(props.pitch_inertia_kg_m2 > 0.0);
1130    }
1131
1132    #[test]
1133    fn elliptical_and_parabolic_noses_use_the_nasa_tables() {
1134        let elliptical = RocketGeometry::new(
1135            NoseCone {
1136                shape: NoseShape::Elliptical,
1137                length_m: 0.072,
1138                diameter_m: 0.024,
1139            },
1140            example().tube,
1141            example().fins,
1142        );
1143        // Fineness 3 reproduces the table value at Mach 1.4 (0.148).
1144        assert!(
1145            (elliptical.nose_pressure_cd(1.4) - 0.148).abs() < 0.005,
1146            "{}",
1147            elliptical.nose_pressure_cd(1.4)
1148        );
1149        // Subsonic is ~zero (the 99% surface angle is tiny).
1150        assert!(elliptical.nose_pressure_cd(0.5) < 0.02);
1151        let parabolic = RocketGeometry::new(
1152            NoseCone {
1153                shape: NoseShape::Parabolic,
1154                length_m: 0.072,
1155                diameter_m: 0.024,
1156            },
1157            example().tube,
1158            example().fins,
1159        );
1160        // Parabolic table is zero below 0.95 and 0.092 at 1.05.
1161        assert!(parabolic.nose_pressure_cd(0.8) < 1e-4);
1162        assert!(
1163            (parabolic.nose_pressure_cd(1.05) - 0.092).abs() < 0.005,
1164            "{}",
1165            parabolic.nose_pressure_cd(1.05)
1166        );
1167        // Stagnation Cd is continuous across Mach 1.
1168        let lo = RocketGeometry::stagnation_cd(0.999);
1169        let hi = RocketGeometry::stagnation_cd(1.001);
1170        assert!((lo - hi).abs() < 0.01, "{lo} vs {hi}");
1171    }
1172
1173    #[test]
1174    fn fin_cp_moves_aft_supersonic() {
1175        let geo = example();
1176        assert_eq!(geo.fin_cp_fraction(0.0), 0.25);
1177        assert_eq!(geo.fin_cp_fraction(0.5), 0.25);
1178        // Supersonic empirical formula: (AR·β − 0.67)/(2·AR·β − 1).
1179        let ar = 2.0 * 0.04_f64.powi(2) / geo.fin_area();
1180        let beta = (4.0_f64 - 1.0).sqrt();
1181        let expected = (ar * beta - 0.67) / (2.0 * ar * beta - 1.0);
1182        assert!((geo.fin_cp_fraction(2.0) - expected).abs() < 1e-12);
1183        assert!(geo.fin_cp_fraction(1.0) > 0.25 && geo.fin_cp_fraction(1.0) < expected);
1184    }
1185
1186    #[test]
1187    fn conical_nose_volume_properties_match_the_solid_cone() {
1188        let geo = example(); // conical nose, L = 0.1, R = 0.012
1189        let (cg, inertia) = geo.nose_volume_properties();
1190        assert!((cg - 0.75 * 0.1).abs() < 1e-6, "cg {cg}");
1191        let expected = 3.0 * 0.012_f64.powi(2) / 20.0 + 3.0 * 0.1_f64.powi(2) / 80.0;
1192        assert!(
1193            (inertia - expected).abs() / expected < 1e-4,
1194            "inertia {inertia} expected {expected}"
1195        );
1196    }
1197
1198    #[test]
1199    fn fin_centroid_matches_polygon_limits() {
1200        let geo = example();
1201        // Rectangle limit (m = 0, ct = cr): centroid at cr/2, height s/2.
1202        let rectangular = RocketGeometry::new(
1203            geo.nose,
1204            geo.tube,
1205            TrapezoidalFins {
1206                count: 3,
1207                root_chord_m: 0.05,
1208                tip_chord_m: 0.05,
1209                span_m: 0.04,
1210                leading_sweep_m: 0.0,
1211                thickness_m: 0.002,
1212            },
1213        );
1214        assert!((rectangular.fin_planform_cg() - 0.025).abs() < 1e-12);
1215        assert!((rectangular.fin_radial_cg() - 0.02).abs() < 1e-12);
1216        // Triangle limit (ct = 0): (cr + m)/3 from the leading edge, s/3 up.
1217        let triangular = RocketGeometry::new(
1218            geo.nose,
1219            geo.tube,
1220            TrapezoidalFins {
1221                count: 3,
1222                root_chord_m: 0.06,
1223                tip_chord_m: 0.0,
1224                span_m: 0.03,
1225                leading_sweep_m: 0.03,
1226                thickness_m: 0.002,
1227            },
1228        );
1229        assert!((triangular.fin_planform_cg() - (0.06 + 0.03) / 3.0).abs() < 1e-12);
1230        assert!((triangular.fin_radial_cg() - 0.01).abs() < 1e-12);
1231    }
1232
1233    #[test]
1234    fn mass_properties_are_consistent() {
1235        let geo = example();
1236        let props = geo.mass_properties(0.012, 0.020, 0.006, None);
1237        assert!((props.dry_mass_kg - 0.038).abs() < 1e-12);
1238        // CG is the mass-weighted component mean, inside the airframe.
1239        let (nose_cg, _) = geo.nose_volume_properties();
1240        let tube_cg = geo.nose.length_m + geo.tube.length_m / 2.0;
1241        let fin_cg = geo.total_length_m() - geo.fins.root_chord_m + geo.fin_planform_cg();
1242        let expected_cg = (0.012 * nose_cg + 0.020 * tube_cg + 0.006 * fin_cg) / 0.038;
1243        assert!((props.dry_cg_m - expected_cg).abs() < 1e-12);
1244        assert!(props.dry_cg_m > 0.0 && props.dry_cg_m < geo.total_length_m());
1245        assert!(props.pitch_inertia_kg_m2 > 0.0);
1246        // A casing at the aft end moves the CG aft and adds inertia.
1247        let with_casing = geo.mass_properties(0.012, 0.020, 0.006, Some((0.02, 0.32)));
1248        assert!(with_casing.dry_cg_m > props.dry_cg_m);
1249        assert!(with_casing.pitch_inertia_kg_m2 > props.pitch_inertia_kg_m2);
1250        assert!((with_casing.dry_mass_kg - 0.058).abs() < 1e-12);
1251        // A heavier nose moves the CG forward.
1252        let heavy_nose = geo.mass_properties(0.024, 0.020, 0.006, None);
1253        assert!(heavy_nose.dry_cg_m < props.dry_cg_m);
1254    }
1255
1256    #[test]
1257    fn induced_k_tracks_cna() {
1258        // Tilt model: the induced-drag factor equals the normal-force slope.
1259        let geo = example();
1260        let analysis = geo.analyze();
1261        for ((m, k), (_, cna)) in analysis.induced_k_vs_mach.iter().zip(&analysis.cna_vs_mach) {
1262            assert_eq!(k, cna, "mach {m}");
1263        }
1264    }
1265
1266    #[test]
1267    fn mass_properties_single_component_is_exact() {
1268        let geo = example();
1269        let props = geo.mass_properties(0.0, 0.05, 0.0, None);
1270        // Only the tube: CG at its midpoint, inertia its thin-cylinder value.
1271        let tube_cg = geo.nose.length_m + geo.tube.length_m / 2.0;
1272        assert!((props.dry_cg_m - tube_cg).abs() < 1e-12);
1273        let expected =
1274            0.05 * (geo.tube.length_m.powi(2) / 12.0 + geo.tube.diameter_m.powi(2) / 8.0);
1275        assert!(
1276            (props.pitch_inertia_kg_m2 - expected).abs() / expected < 1e-12,
1277            "{} vs {expected}",
1278            props.pitch_inertia_kg_m2
1279        );
1280    }
1281
1282    #[test]
1283    fn fin_set_cna_matches_diederich() {
1284        let geo = example();
1285        // Fin pair: 2π·span²/(1+sqrt(1+(span²/(A·cosΓ))²)) / Aref, then
1286        // (N/2)·(1+tau) — the Diederich value is a wing (two fins).
1287        let area = geo.fin_area();
1288        let cos = geo.midchord_cos();
1289        let ratio = geo.fins.span_m * geo.fins.span_m / (area * cos);
1290        let one = 2.0 * std::f64::consts::PI * geo.fins.span_m * geo.fins.span_m
1291            / (1.0 + (1.0 + ratio * ratio).sqrt())
1292            / geo.ref_area_m2();
1293        let expected = 1.5 * one * (1.0 + geo.interference_tau());
1294        let actual = geo.fin_set_cna(0.0);
1295        assert!(
1296            (actual - expected).abs() / expected < 1e-9,
1297            "actual {actual} expected {expected}"
1298        );
1299        // And the classic closed form 4N(s/d)²/(1+sqrt(1+(2l/(cr+ct))²))·K.
1300        let d = geo.max_diameter_m();
1301        let classic = 4.0 * 3.0 * (geo.fins.span_m / d).powi(2)
1302            / (1.0 + (1.0_f64 + 1.0_f64).sqrt())
1303            * (1.0 + geo.interference_tau());
1304        assert!(
1305            (actual - classic).abs() / classic < 0.02,
1306            "actual {actual} classic {classic}"
1307        );
1308    }
1309
1310    #[test]
1311    fn total_cp_is_the_moment_sum() {
1312        let geo = example();
1313        let fin_cna = geo.fin_set_cna(0.0);
1314        let expected = (2.0 * geo.nose_cp() + fin_cna * geo.fin_cp_abs(0.0)) / (2.0 + fin_cna);
1315        assert!((geo.total_cp(0.0) - expected).abs() < 1e-12);
1316        // CP must sit aft of the nose CP (fins pull it back).
1317        assert!(geo.total_cp(0.0) > geo.nose_cp());
1318    }
1319
1320    #[test]
1321    fn base_cd_matches_openrocket() {
1322        assert_eq!(RocketGeometry::base_cd(0.0), 0.12);
1323        assert_eq!(RocketGeometry::base_cd(1.0), 0.25);
1324        assert_eq!(RocketGeometry::base_cd(2.0), 0.125);
1325    }
1326
1327    #[test]
1328    fn analysis_produces_finite_curves_and_roughness_costs_drag() {
1329        let geo = example();
1330        let analysis = geo.analyze();
1331        for &(m, cd) in &analysis.cd_vs_mach {
1332            assert!(cd.is_finite() && cd > 0.0, "mach {m} cd {cd}");
1333        }
1334        for &(m, cp) in &analysis.cp_vs_mach {
1335            assert!(cp.is_finite() && cp > 0.0, "mach {m} cp {cp}");
1336        }
1337        let smooth = geo.with_roughness(1e-6).analyze();
1338        let rough = geo.with_roughness(500e-6).analyze();
1339        assert!(rough.cd_vs_mach[2].1 > smooth.cd_vs_mach[2].1);
1340    }
1341}