Skip to main content

rockets_engine/
geometry.rs

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