Skip to main content

hpr/rocket/
mod.rs

1//! A rocket built part by part, from the nose back.
2//!
3//! [`Rocket::new`] starts an empty rocket of one body diameter. Body parts ([`Nose`], [`Tube`],
4//! [`Transition`]) stack from the nose tip in the order they are added. Attached parts ([`Fins`],
5//! [`MotorTube`], [`Mass`]) go on the last body tube added, where their [`Position`] puts them.
6//! [`Fitting`]s (couplers, centering rings, bulkheads, launch lugs, packed parachutes and
7//! streamers) go there too. [`Rocket::set_motor`] puts a [`Motor`] in the motor tube, and
8//! [`Rocket::add_parachute`] adds a recovery device. What the builder makes is an ordinary
9//! [`hpr_design::Rocket`], the same tree a design file holds ([`Rocket::design`]); a design you
10//! already have flies through [`Rocket::from_design`].
11//!
12//! Every part names its material. [`material`] finds a built-in one by id; the list, with each
13//! density's source, is [`hpr_design::materials`]. The builder has no default materials or wall
14//! thicknesses, because each one would be a guess at your rocket's mass. Every outer surface has
15//! the design's default finish, mass-production paint ([`hpr_design::Finish`]), which sets its
16//! skin friction; the builder can't change it yet.
17//!
18//! Parts can also come from a parts catalog, such as the one OpenRocket ships
19//! ([`hpr_io::orc::bundled`]): [`Nose::from_catalog`], [`Tube::from_catalog`],
20//! [`Transition::from_catalog`], [`MotorTube::from_catalog`] and [`Fitting::from_catalog`] make
21//! each part as the catalog gives it, its material and stated mass included. What a catalog
22//! leaves unsaid is chosen as OpenRocket chooses it, but for a hollow part's shoulder wall
23//! ([`catalog`] lists each choice).
24
25use hpr_aero::AeroModel;
26use hpr_design::checks::{check, has_errors};
27use hpr_design::{
28    Assembly, AutoDimension, BodyTube, Component, Configuration, FinCrossSection, FinPlanform,
29    FinSet, Ignition, InnerTube, MassComponent, MassProperties, Material, MotorMount, MountedMotor,
30    NoseCone, NoseShape, Overrides, Packing, Part, Position, Profile, ReferenceDiameter, Shoulder,
31    Stage, Wall, materials,
32};
33use hpr_sim::Device;
34use hpr_sim::metrics::{self, Margin};
35use serde::{Deserialize, Serialize};
36
37use crate::error::{Error, Order, finite, non_negative, positive};
38use crate::motor::Motor;
39
40pub mod catalog;
41
42pub use catalog::Fitting;
43
44/// The built-in material with id `id`, such as `"abs"`, `"kraft_phenolic"` or
45/// `"birch_plywood"`. [`hpr_design::materials`] lists them all, each with its density's source.
46///
47/// # Errors
48///
49/// [`Error::UnknownMaterial`] if no built-in material has that id.
50pub fn material(id: &str) -> Result<Material, Error> {
51    materials::find(id)
52        .map(|builtin| builtin.material())
53        .ok_or_else(|| Error::UnknownMaterial(id.to_owned()))
54}
55
56/// A nose cone. Its base takes the rocket's diameter, unless it states its own, as a catalog
57/// part does ([`Nose::from_catalog`]).
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct Nose {
60    shape: NoseShape,
61    length_m: f64,
62    wall: Wall,
63    /// The shoulder; an outer radius of zero takes the inner radius of the tube behind it.
64    shoulder: Option<Shoulder>,
65    material: Material,
66    name: String,
67    /// The base diameter, m, where the nose states its own: a catalog part's.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    diameter_m: Option<f64>,
70}
71
72impl Nose {
73    /// A hollow nose cone of `shape` and `length_m`, with a wall `wall_m` thick.
74    #[must_use]
75    pub fn hollow(shape: NoseShape, length_m: f64, wall_m: f64, material: Material) -> Self {
76        Self {
77            shape,
78            length_m,
79            wall: Wall::Shell {
80                thickness_m: wall_m,
81            },
82            shoulder: None,
83            material,
84            name: String::new(),
85            diameter_m: None,
86        }
87    }
88
89    /// A solid nose cone of `shape` and `length_m`.
90    #[must_use]
91    pub fn solid(shape: NoseShape, length_m: f64, material: Material) -> Self {
92        Self {
93            wall: Wall::Filled {},
94            ..Self::hollow(shape, length_m, 0.0, material)
95        }
96    }
97
98    /// The same nose with a shoulder, the sleeve behind its base that fits inside the tube
99    /// behind it: `length_m` long, `wall_m` thick, open at its aft end. Its outer radius is that
100    /// tube's inner radius.
101    #[must_use]
102    pub fn with_shoulder(mut self, length_m: f64, wall_m: f64) -> Self {
103        self.shoulder = Some(Shoulder {
104            length_m,
105            outer_radius_m: 0.0,
106            thickness_m: wall_m,
107            capped: false,
108        });
109        self
110    }
111
112    /// The same nose with a shoulder as [`Nose::with_shoulder`] makes it, closed at its aft end
113    /// by a disc as thick as its wall.
114    #[must_use]
115    pub fn with_capped_shoulder(self, length_m: f64, wall_m: f64) -> Self {
116        let mut nose = self.with_shoulder(length_m, wall_m);
117        if let Some(shoulder) = &mut nose.shoulder {
118            shoulder.capped = true;
119        }
120        nose
121    }
122
123    /// The same nose with `name`, which the design file keeps.
124    #[must_use]
125    pub fn named(mut self, name: &str) -> Self {
126        name.clone_into(&mut self.name);
127        self
128    }
129}
130
131/// A body tube: a length of the airframe.
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct Tube {
134    length_m: f64,
135    wall_m: f64,
136    diameter_m: Option<f64>,
137    material: Material,
138    name: String,
139}
140
141impl Tube {
142    /// A tube `length_m` long with a wall `wall_m` thick. Its outer diameter is the aft diameter
143    /// of the body part before it, or the rocket's if it is the first.
144    #[must_use]
145    pub fn new(length_m: f64, wall_m: f64, material: Material) -> Self {
146        Self {
147            length_m,
148            wall_m,
149            diameter_m: None,
150            material,
151            name: String::new(),
152        }
153    }
154
155    /// The same tube with outer diameter `diameter_m`. A diameter other than the aft diameter of
156    /// the part before it makes a step in the airframe, which the design's checks warn of; a
157    /// [`Transition`] joins two diameters smoothly.
158    #[must_use]
159    pub fn with_diameter_m(mut self, diameter_m: f64) -> Self {
160        self.diameter_m = Some(diameter_m);
161        self
162    }
163
164    /// The same tube with `name`, which the design file keeps.
165    #[must_use]
166    pub fn named(mut self, name: &str) -> Self {
167        name.clone_into(&mut self.name);
168        self
169    }
170}
171
172/// A transition between two diameters: a boattail narrowing toward the tail, or a cone stepping
173/// the airframe up or down. Its fore end takes the diameter of the body part before it.
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct Transition {
176    shape: NoseShape,
177    length_m: f64,
178    aft_diameter_m: f64,
179    wall: Wall,
180    material: Material,
181    name: String,
182    /// The fore diameter, m, where the transition states its own: a catalog part's.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    fore_diameter_m: Option<f64>,
185    /// Whether its profile is cut from a whole nose cone ([`hpr_design::Transition::clipped`]).
186    #[serde(default)]
187    clipped: bool,
188    /// The shoulder ahead of its fore end, its outer radius stated.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    fore_shoulder: Option<Shoulder>,
191    /// The shoulder behind its aft end, its outer radius stated.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    aft_shoulder: Option<Shoulder>,
194}
195
196impl Transition {
197    /// A hollow conical transition `length_m` long, to `aft_diameter_m` at its aft end, with a
198    /// wall `wall_m` thick.
199    #[must_use]
200    pub fn conical(length_m: f64, aft_diameter_m: f64, wall_m: f64, material: Material) -> Self {
201        Self {
202            shape: NoseShape::Conical {},
203            length_m,
204            aft_diameter_m,
205            wall: Wall::Shell {
206                thickness_m: wall_m,
207            },
208            material,
209            name: String::new(),
210            fore_diameter_m: None,
211            clipped: false,
212            fore_shoulder: None,
213            aft_shoulder: None,
214        }
215    }
216
217    /// The same transition with the profile `shape` instead of a cone. It stays clipped, or
218    /// not, as it was ([`Transition::with_clipped`]).
219    #[must_use]
220    pub fn with_shape(mut self, shape: NoseShape) -> Self {
221        self.shape = shape;
222        self
223    }
224
225    /// The same transition with its profile cut from a whole nose cone (`true`), or scaled
226    /// between its two radii (`false`, as [`Transition::conical`] makes it):
227    /// [`hpr_design::Transition::clipped`]. A conical or tangent-ogive transition is the same
228    /// either way.
229    #[must_use]
230    pub fn with_clipped(mut self, clipped: bool) -> Self {
231        self.clipped = clipped;
232        self
233    }
234
235    /// The same transition, solid.
236    #[must_use]
237    pub fn solid(mut self) -> Self {
238        self.wall = Wall::Filled {};
239        self
240    }
241
242    /// The same transition with `name`, which the design file keeps.
243    #[must_use]
244    pub fn named(mut self, name: &str) -> Self {
245        name.clone_into(&mut self.name);
246        self
247    }
248}
249
250/// A set of identical fins spaced evenly around the last body tube, flush with its aft end
251/// unless placed elsewhere with [`Fins::at`]. Their edges are square unless
252/// [`Fins::with_cross_section`] says otherwise.
253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
254pub struct Fins {
255    set: FinSet,
256    position: Position,
257    name: String,
258}
259
260impl Fins {
261    /// `count` fins of the shape `planform`, `thickness_m` thick. The planform names its
262    /// dimensions: a trapezoid's root and tip chords, its span, and how far aft of the root's
263    /// leading edge the tip's is ([`FinPlanform`]).
264    ///
265    /// ```
266    /// use hpr::FinPlanform;
267    /// use hpr::rocket::{Fins, material};
268    ///
269    /// let planform = FinPlanform::Trapezoidal {
270    ///     root_chord_m: 0.1,
271    ///     tip_chord_m: 0.04,
272    ///     span_m: 0.045,
273    ///     sweep_m: 0.05,
274    /// };
275    /// let fins = Fins::new(3, planform, 0.003175, material("birch_plywood")?);
276    /// # Ok::<(), hpr::Error>(())
277    /// ```
278    #[must_use]
279    pub fn new(count: u32, planform: FinPlanform, thickness_m: f64, material: Material) -> Self {
280        Self {
281            set: FinSet {
282                count,
283                planform,
284                thickness_m,
285                cross_section: FinCrossSection::Square,
286                tab: None,
287                fillet: None,
288                cant_rad: 0.0,
289                base_angle_rad: 0.0,
290                material,
291            },
292            position: Position::Bottom { aft_offset_m: 0.0 },
293            name: String::new(),
294        }
295    }
296
297    /// The same fins with edges shaped `cross_section`: square, rounded or an airfoil. It changes
298    /// their drag.
299    #[must_use]
300    pub fn with_cross_section(mut self, cross_section: FinCrossSection) -> Self {
301        self.set.cross_section = cross_section;
302        self
303    }
304
305    /// The same fins canted by `cant_deg`, which spins the rocket (the sign convention is
306    /// [`FinSet::cant_rad`]'s).
307    #[must_use]
308    pub fn with_cant_deg(mut self, cant_deg: f64) -> Self {
309        self.set.cant_rad = cant_deg.to_radians();
310        self
311    }
312
313    /// The same fins at `position` along their tube: [`Position`] places their root's leading
314    /// edge (`Top`, `After`, `Absolute`), its trailing edge (`Bottom`) or its middle (`Middle`).
315    #[must_use]
316    pub fn at(mut self, position: Position) -> Self {
317        self.position = position;
318        self
319    }
320
321    /// The same fins with `name`, which the design file keeps.
322    #[must_use]
323    pub fn named(mut self, name: &str) -> Self {
324        name.clone_into(&mut self.name);
325        self
326    }
327}
328
329/// The tube inside the airframe that holds the motor, flush with the last body tube's aft end
330/// unless placed elsewhere with [`MotorTube::at`].
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332pub struct MotorTube {
333    length_m: f64,
334    inner_diameter_m: f64,
335    wall_m: f64,
336    overhang_m: f64,
337    position: Position,
338    material: Material,
339    name: String,
340}
341
342impl MotorTube {
343    /// A motor tube `length_m` long, with a bore of `inner_diameter_m` and a wall `wall_m` thick.
344    #[must_use]
345    pub fn new(length_m: f64, inner_diameter_m: f64, wall_m: f64, material: Material) -> Self {
346        Self {
347            length_m,
348            inner_diameter_m,
349            wall_m,
350            overhang_m: 0.0,
351            position: Position::Bottom { aft_offset_m: 0.0 },
352            material,
353            name: String::new(),
354        }
355    }
356
357    /// The same tube with the motor's aft end `overhang_m` past the tube's.
358    #[must_use]
359    pub fn with_overhang_m(mut self, overhang_m: f64) -> Self {
360        self.overhang_m = overhang_m;
361        self
362    }
363
364    /// The same tube at `position` along the body tube it is in.
365    #[must_use]
366    pub fn at(mut self, position: Position) -> Self {
367        self.position = position;
368        self
369    }
370
371    /// The same tube with `name`, which the design file keeps.
372    #[must_use]
373    pub fn named(mut self, name: &str) -> Self {
374        name.clone_into(&mut self.name);
375        self
376    }
377}
378
379/// A mass inside the last body tube: a parachute and its cord, an altimeter bay, ballast.
380///
381/// It is a solid cylinder along the axis, as long and as wide as its packing, or a point until
382/// [`Mass::packed`] gives it a size. Its [`Position`] places the packing's fore end (`Top`,
383/// `After`, `Absolute`), its aft end (`Bottom`) or its middle (`Middle`). So packing a mass
384/// moves its center by half the packed length, unless it is placed by its middle.
385#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
386pub struct Mass {
387    mass_kg: f64,
388    position: Position,
389    packed_length_m: f64,
390    packed_diameter_m: f64,
391    name: String,
392}
393
394impl Mass {
395    /// `mass_kg` at `position` along the body tube it is in.
396    #[must_use]
397    pub fn new(mass_kg: f64, position: Position) -> Self {
398        Self {
399            mass_kg,
400            position,
401            packed_length_m: 0.0,
402            packed_diameter_m: 0.0,
403            name: String::new(),
404        }
405    }
406
407    /// The same mass packed in a cylinder `length_m` long and `diameter_m` across.
408    #[must_use]
409    pub fn packed(mut self, length_m: f64, diameter_m: f64) -> Self {
410        self.packed_length_m = length_m;
411        self.packed_diameter_m = diameter_m;
412        self
413    }
414
415    /// The same mass with `name`, which the design file keeps.
416    #[must_use]
417    pub fn named(mut self, name: &str) -> Self {
418        name.clone_into(&mut self.name);
419        self
420    }
421}
422
423/// A rocket: its design, its motor and its recovery devices.
424///
425/// It serializes, for a record of what was flown, but doesn't deserialize: the builder's methods
426/// check what goes in, so a rocket is built with them, or read from a design with
427/// [`Rocket::from_design`].
428///
429/// ```
430/// use hpr::rocket::{Fins, Mass, MotorTube, Nose, Tube, material};
431/// use hpr::{FinPlanform, Motor, NoseShape, Position, Rocket};
432///
433/// let mut rocket = Rocket::new("Small", 0.0563)?;
434/// let ogive = NoseShape::Ogive { radius_ratio: 1.0 };
435/// let planform = FinPlanform::Trapezoidal {
436///     root_chord_m: 0.1,
437///     tip_chord_m: 0.04,
438///     span_m: 0.045,
439///     sweep_m: 0.05,
440/// };
441/// rocket
442///     .add_nose(Nose::hollow(ogive, 0.22, 0.0015, material("abs")?))?
443///     .add_tube(Tube::new(0.9, 0.00115, material("kraft_phenolic")?))?
444///     .add_fins(Fins::new(3, planform, 0.003175, material("birch_plywood")?))?
445///     .add_motor_tube(MotorTube::new(0.2, 0.029, 0.001, material("kraft_phenolic")?))?
446///     .set_motor(Motor::from_catalog("H54")?)?;
447///
448/// // Unstable as it stands: the motor's weight puts the center of gravity behind the center of
449/// // pressure, and the margin, in calibres, is negative.
450/// assert!(rocket.static_margin_cal(0.0, 0.3)?.is_some_and(|margin| margin < 0.0));
451/// // 200 g near the top of the tube, a recovery bay, brings it forward of it.
452/// rocket.add_mass(Mass::new(0.2, Position::Top { aft_offset_m: 0.07 }))?;
453/// assert!(rocket.static_margin_cal(0.0, 0.3)?.is_some_and(|margin| margin > 1.0));
454/// # Ok::<(), hpr::Error>(())
455/// ```
456#[derive(Debug, Clone, PartialEq, Serialize)]
457pub struct Rocket {
458    design: hpr_design::Rocket,
459    configuration_id: Option<String>,
460    recovery: Vec<Device>,
461    /// Where the builder has got to; `None` for a rocket read from a design. Not part of the
462    /// record.
463    #[serde(skip)]
464    build: Option<Build>,
465}
466
467/// The builder's place in the tree.
468#[derive(Debug, Clone, PartialEq)]
469struct Build {
470    /// The rocket's body diameter, m.
471    diameter_m: f64,
472    /// The last body part's aft radius, m; `None` before the first.
473    aft_radius_m: Option<f64>,
474    /// The index of the last body tube among the stage's components.
475    tube: Option<usize>,
476    /// The motor tube's component id, once there is one.
477    motor_tube: Option<String>,
478}
479
480/// The id of the stage the builder makes.
481const STAGE_ID: &str = "sustainer";
482
483impl Rocket {
484    /// An empty rocket called `name`, of outer body diameter `diameter_m`: the nose's base
485    /// diameter, and the first tube's, unless they say otherwise (a catalog part states its
486    /// own). Its reference diameter, the one
487    /// the stability margin is counted in, is its largest body diameter.
488    ///
489    /// # Errors
490    ///
491    /// [`Error::Domain`] for a diameter that isn't finite and positive.
492    pub fn new(name: &str, diameter_m: f64) -> Result<Self, Error> {
493        Ok(Self {
494            design: hpr_design::Rocket {
495                name: name.to_owned(),
496                stages: vec![Stage {
497                    id: STAGE_ID.to_owned(),
498                    name: String::new(),
499                    components: Vec::new(),
500                    overrides: Overrides::default(),
501                    drag_override: None,
502                    parallel: None,
503                }],
504                reference_diameter: ReferenceDiameter::Maximum {},
505                configurations: Vec::new(),
506            },
507            configuration_id: None,
508            recovery: Vec::new(),
509            build: Some(Build {
510                diameter_m: positive("rocket diameter, m", diameter_m)?,
511                aft_radius_m: None,
512                tube: None,
513                motor_tube: None,
514            }),
515        })
516    }
517
518    /// A rocket from a design you already have, flown in its configuration `configuration_id`:
519    /// a design file read with `serde_json`, an OpenRocket file read with [`hpr_io::ork`], or a
520    /// built rocket's [`Rocket::design`] changed with [`hpr_design`] to hold what the builder
521    /// can't, such as a cluster or pods. Parts can't be added to it; recovery devices can.
522    ///
523    /// # Errors
524    ///
525    /// [`Error::NoSuchConfiguration`] if the design has no configuration of that id.
526    pub fn from_design(design: hpr_design::Rocket, configuration_id: &str) -> Result<Self, Error> {
527        if design.configuration(configuration_id).is_none() {
528            return Err(Error::NoSuchConfiguration(configuration_id.to_owned()));
529        }
530        Ok(Self {
531            design,
532            configuration_id: Some(configuration_id.to_owned()),
533            recovery: Vec::new(),
534            build: None,
535        })
536    }
537
538    /// Adds the nose cone, which goes first.
539    ///
540    /// # Errors
541    ///
542    /// - [`Error::Order`] if a body part is already in place, or the rocket was read from a
543    ///   design.
544    /// - [`Error::Domain`] for a diameter, length, wall or shoulder dimension that isn't finite
545    ///   and positive, or a shoulder wall thicker than its stated radius; [`Error::Design`] for a
546    ///   shape whose parameter is out of its range.
547    pub fn add_nose(&mut self, nose: Nose) -> Result<&mut Self, Error> {
548        let build = self.build()?;
549        if build.aft_radius_m.is_some() {
550            return Err(Error::Order(Order::NoseNotFirst));
551        }
552        let radius_m = 0.5
553            * positive(
554                "nose diameter, m",
555                nose.diameter_m.unwrap_or(build.diameter_m),
556            )?;
557        positive("nose length, m", nose.length_m)?;
558        check_wall("nose wall, m", nose.wall)?;
559        Profile::nose(nose.shape, nose.length_m, radius_m)?;
560        let mut auto = Vec::new();
561        if let Some(shoulder) = &nose.shoulder {
562            check_shoulder(&NOSE_SHOULDER, shoulder)?;
563            if shoulder.outer_radius_m == 0.0 {
564                auto.push(AutoDimension::ShoulderRadius);
565            }
566        }
567        let part = Part::NoseCone(NoseCone {
568            shape: nose.shape,
569            length_m: nose.length_m,
570            base_radius_m: radius_m,
571            wall: nose.wall,
572            shoulder: nose.shoulder,
573            material: nose.material,
574        });
575        let mut component = self.component("nose", &nose.name, part, None);
576        component.auto = auto;
577        self.push_body(component, radius_m, false)?;
578        Ok(self)
579    }
580
581    /// Adds a body tube behind the last body part.
582    ///
583    /// # Errors
584    ///
585    /// - [`Error::Order`] if the rocket was read from a design.
586    /// - [`Error::Domain`] for a length, wall or diameter that isn't finite and positive.
587    pub fn add_tube(&mut self, tube: Tube) -> Result<&mut Self, Error> {
588        let build = self.build()?;
589        let before_m = build
590            .aft_radius_m
591            .map_or(build.diameter_m, |radius_m| 2.0 * radius_m);
592        let diameter_m = positive("tube diameter, m", tube.diameter_m.unwrap_or(before_m))?;
593        let part = Part::BodyTube(BodyTube {
594            length_m: positive("tube length, m", tube.length_m)?,
595            outer_radius_m: 0.5 * diameter_m,
596            thickness_m: positive("tube wall, m", tube.wall_m)?,
597            material: tube.material,
598        });
599        let component = self.component("tube", &tube.name, part, None);
600        self.push_body(component, 0.5 * diameter_m, true)?;
601        Ok(self)
602    }
603
604    /// Adds a transition behind the last body part, starting at its diameter unless it states its
605    /// own (a catalog part does).
606    ///
607    /// # Errors
608    ///
609    /// - [`Error::Order`] if there is no body part before it, or the rocket was read from a
610    ///   design.
611    /// - [`Error::Domain`] for a length, wall, stated fore diameter or shoulder dimension that
612    ///   isn't finite and positive, an aft diameter that is negative or not finite, or a
613    ///   shoulder wall thicker than its radius; [`Error::Design`] for a shape whose parameter is
614    ///   out of its range.
615    pub fn add_transition(&mut self, transition: Transition) -> Result<&mut Self, Error> {
616        let before_radius_m = self
617            .build()?
618            .aft_radius_m
619            .ok_or(Error::Order(Order::NothingBeforeTransition))?;
620        let fore_radius_m = match transition.fore_diameter_m {
621            Some(diameter_m) => 0.5 * positive("transition fore diameter, m", diameter_m)?,
622            None => before_radius_m,
623        };
624        positive("transition length, m", transition.length_m)?;
625        check_wall("transition wall, m", transition.wall)?;
626        let aft_radius_m =
627            0.5 * non_negative("transition aft diameter, m", transition.aft_diameter_m)?;
628        Profile::transition(
629            transition.shape,
630            transition.length_m,
631            fore_radius_m,
632            aft_radius_m,
633            transition.clipped,
634        )?;
635        for shoulder in [&transition.fore_shoulder, &transition.aft_shoulder]
636            .into_iter()
637            .flatten()
638        {
639            // A transition's shoulders state their radius: the builder finds none for them.
640            positive("transition shoulder radius, m", shoulder.outer_radius_m)?;
641            check_shoulder(&TRANSITION_SHOULDER, shoulder)?;
642        }
643        let part = Part::Transition(hpr_design::Transition {
644            shape: transition.shape,
645            clipped: transition.clipped,
646            length_m: transition.length_m,
647            fore_radius_m,
648            aft_radius_m,
649            wall: transition.wall,
650            fore_shoulder: transition.fore_shoulder,
651            aft_shoulder: transition.aft_shoulder,
652            material: transition.material,
653        });
654        let component = self.component("transition", &transition.name, part, None);
655        self.push_body(component, aft_radius_m, false)?;
656        Ok(self)
657    }
658
659    /// Adds a fin set to the last body tube.
660    ///
661    /// # Errors
662    ///
663    /// - [`Error::Order`] if there is no body tube yet, or the rocket was read from a design.
664    /// - [`Error::Design`] for fins the design refuses ([`FinSet::validate`]): no fins, a chord,
665    ///   span or thickness that isn't finite and positive, and the like.
666    /// - [`Error::Domain`] for a position that isn't finite.
667    pub fn add_fins(&mut self, fins: Fins) -> Result<&mut Self, Error> {
668        fins.set.validate()?;
669        check_position(fins.position)?;
670        let component = self.component(
671            "fins",
672            &fins.name,
673            Part::FinSet(fins.set),
674            Some(fins.position),
675        );
676        self.attach(component)?;
677        Ok(self)
678    }
679
680    /// Adds the motor tube to the last body tube. A rocket has one.
681    ///
682    /// # Errors
683    ///
684    /// - [`Error::Order`] if there is no body tube yet, the rocket has a motor tube already, or
685    ///   it was read from a design.
686    /// - [`Error::Domain`] for a length, bore or wall that isn't finite and positive, an overhang
687    ///   that is negative or not finite, or a position that isn't finite.
688    pub fn add_motor_tube(&mut self, tube: MotorTube) -> Result<&mut Self, Error> {
689        if self.build()?.motor_tube.is_some() {
690            return Err(Error::Order(Order::SecondMotorTube));
691        }
692        let wall_m = positive("motor tube wall, m", tube.wall_m)?;
693        let part = Part::InnerTube(InnerTube {
694            length_m: positive("motor tube length, m", tube.length_m)?,
695            outer_radius_m: 0.5 * positive("motor tube bore, m", tube.inner_diameter_m)? + wall_m,
696            thickness_m: wall_m,
697            radial_offset_m: 0.0,
698            angle_rad: 0.0,
699            material: tube.material,
700            cluster_m: Vec::new(),
701        });
702        check_position(tube.position)?;
703        let mut component = self.component("motor-tube", &tube.name, part, Some(tube.position));
704        component.motor_mount = Some(MotorMount {
705            overhang_m: non_negative("motor overhang, m", tube.overhang_m)?,
706        });
707        let id = component.id.clone();
708        self.attach(component)?;
709        if let Some(build) = &mut self.build {
710            build.motor_tube = Some(id);
711        }
712        Ok(self)
713    }
714
715    /// Adds a mass inside the last body tube.
716    ///
717    /// # Errors
718    ///
719    /// - [`Error::Order`] if there is no body tube yet, or the rocket was read from a design.
720    /// - [`Error::Domain`] for a mass or packing that is negative or not finite, or a position
721    ///   that isn't finite.
722    pub fn add_mass(&mut self, mass: Mass) -> Result<&mut Self, Error> {
723        non_negative("mass, kg", mass.mass_kg)?;
724        non_negative("packed length, m", mass.packed_length_m)?;
725        non_negative("packed diameter, m", mass.packed_diameter_m)?;
726        check_position(mass.position)?;
727        let part = Part::MassComponent(MassComponent {
728            mass_kg: mass.mass_kg,
729            packing: Packing {
730                length_m: mass.packed_length_m,
731                radius_m: 0.5 * mass.packed_diameter_m,
732                radial_offset_m: 0.0,
733                angle_rad: 0.0,
734            },
735        });
736        let component = self.component("mass", &mass.name, part, Some(mass.position));
737        self.attach(component)?;
738        Ok(self)
739    }
740
741    /// Adds a fitting to the last body tube: a coupler, a centering ring, a bulkhead, a launch
742    /// lug, or a packed parachute or streamer.
743    ///
744    /// # Errors
745    ///
746    /// - [`Error::Order`] if there is no body tube yet, the rocket was read from a design, or the
747    ///   fitting holds a part of another kind (one read from a file).
748    /// - [`Error::Design`] for a part the design can't weigh: a size that is negative or not
749    ///   finite, a ring whose bore reaches its rim, a wall thicker than its tube's radius, a
750    ///   material of the wrong kind. A tube with a wall of zero, or a parachute or streamer of
751    ///   no size, weighs nothing and is taken.
752    /// - [`Error::Domain`] for a position that isn't finite.
753    pub fn add_fitting(&mut self, fitting: Fitting) -> Result<&mut Self, Error> {
754        let id = fitting.id().ok_or(Error::Order(Order::NotAFitting))?;
755        let (part, position, name) = fitting.into_parts();
756        check_position(position)?;
757        // Weighed now, on the tube it goes on, so a part the design can't take is refused where
758        // it is added.
759        part.mass_properties(Some(self.tube_radius_m()?))?;
760        let component = self.component(id, &name, part, Some(position));
761        self.attach(component)?;
762        Ok(self)
763    }
764
765    /// Puts `motor` in the motor tube, lit at launch, in place of any motor there before. The
766    /// design's one configuration is named after its designation.
767    ///
768    /// # Errors
769    ///
770    /// [`Error::Order`] if there is no motor tube yet, or the rocket was read from a design.
771    pub fn set_motor(&mut self, motor: Motor) -> Result<&mut Self, Error> {
772        let mount = self
773            .build()?
774            .motor_tube
775            .clone()
776            .ok_or(Error::Order(Order::NoMotorTube))?;
777        let id = motor.designation().to_owned();
778        self.design.configurations = vec![Configuration {
779            id: id.clone(),
780            name: String::new(),
781            motors: vec![MountedMotor {
782                mount,
783                designation: id.clone(),
784                diameter_m: motor.diameter_m(),
785                length_m: motor.length_m(),
786                motor: motor.solid_motor().clone(),
787                delay: motor.delay(),
788                ignition: Ignition::Launch,
789                failed_tubes: Vec::new(),
790            }],
791        }];
792        self.configuration_id = Some(id);
793        Ok(self)
794    }
795
796    /// Adds a recovery device: a parachute, a streamer or a tumble, with what opens it.
797    /// [`Trigger::MotorDelay`](hpr_sim::Trigger::MotorDelay) with motor 0, the first motor, opens
798    /// it at the motor's ejection charge, which needs the motor's delay set
799    /// ([`Motor::with_delay_s`]). The device adds drag, not mass: add its mass with
800    /// [`Rocket::add_mass`], or a catalog parachute's with [`Fitting::from_catalog`].
801    pub fn add_parachute(&mut self, device: Device) -> &mut Self {
802        self.recovery.push(device);
803        self
804    }
805
806    /// The design: the same tree a design file holds.
807    #[must_use]
808    pub fn design(&self) -> &hpr_design::Rocket {
809        &self.design
810    }
811
812    /// The configuration the rocket flies in: the motor's designation for a built rocket, `None`
813    /// before it has a motor.
814    #[must_use]
815    pub fn configuration_id(&self) -> Option<&str> {
816        self.configuration_id.as_deref()
817    }
818
819    /// The recovery devices, in the order they were added.
820    #[must_use]
821    pub fn recovery(&self) -> &[Device] {
822        &self.recovery
823    }
824
825    /// The parts placed and the motor in its tube ([`hpr_design::Rocket::assemble`]), once the
826    /// design's checks ([`hpr_design::checks`]) find no errors: the same checks a flight runs
827    /// with the default settings, so what this weighs is what [`crate::Flight`] would fly. A
828    /// flight told to accept a design's errors
829    /// ([`FlightSettings::accept_design_errors`](hpr_sim::FlightSettings::accept_design_errors))
830    /// flies what this refuses; `design().assemble(id)` weighs it.
831    ///
832    /// # Errors
833    ///
834    /// - [`Error::NoMotor`] before the rocket has a motor.
835    /// - [`Error::DesignChecks`] with every finding if the checks find an error, such as a motor
836    ///   wider than its tube's bore or a motor tube wider than the airframe.
837    /// - [`Error::Design`] for a tree that doesn't hold together.
838    pub fn assemble(&self) -> Result<Assembly, Error> {
839        let id = self.configuration_id.as_deref().ok_or(Error::NoMotor)?;
840        let findings = check(&self.design)?;
841        if has_errors(&findings) {
842            return Err(Error::DesignChecks(findings));
843        }
844        Ok(self.design.assemble(id)?)
845    }
846
847    /// The mass, center of gravity and inertia `time_s` seconds after the motor lights, the
848    /// motor burning as its thrust curve says. The center of gravity is in the body frame: `z`
849    /// points to the nose and is zero at its tip, so a point `s` meters aft of the tip is at
850    /// `z = −s` (`docs/physics/frames.md`).
851    ///
852    /// # Errors
853    ///
854    /// As [`Rocket::assemble`], and [`Error::Domain`] for a time that is negative or not finite.
855    pub fn mass_properties(&self, time_s: f64) -> Result<MassProperties, Error> {
856        let time_s = non_negative("time, s", time_s)?;
857        Ok(self.assemble()?.mass_properties(time_s))
858    }
859
860    /// The static stability margin `time_s` seconds after the motor lights, at Mach `mach` with
861    /// the air along the axis, in the rocket's weakest plane ([`Rocket::margin`]), calibres of the
862    /// reference diameter: how far the center of pressure is behind the center of gravity. `None`
863    /// where [`hpr_sim::metrics::margin`] leaves it
864    /// undefined: a normal force that doesn't restore, or a center of pressure too ill-conditioned
865    /// to place. [`Rocket::margin`] gives the rest of it.
866    ///
867    /// # Errors
868    ///
869    /// As [`Rocket::margin`].
870    pub fn static_margin_cal(&self, time_s: f64, mach: f64) -> Result<Option<f64>, Error> {
871        Ok(self.margin(time_s, mach)?.margin_cal)
872    }
873
874    /// The center of pressure and the static margin `time_s` seconds after the motor lights, at
875    /// Mach `mach` with the air along the axis, in the weakest plane the air can cross the rocket
876    /// in ([`hpr_sim::metrics::weakest_margin`]): a rocket with a fin set of one or two fins has a
877    /// margin in each plane, and the least is the one to trust. Its center of pressure is a
878    /// station: meters aft of the nose tip, positive.
879    ///
880    /// # Errors
881    ///
882    /// As [`Rocket::mass_properties`], [`Error::Aero`] for a rocket the aerodynamic model can't
883    /// take, and [`Error::Sim`] for a Mach number out of its range.
884    pub fn margin(&self, time_s: f64, mach: f64) -> Result<Margin, Error> {
885        let time_s = non_negative("time, s", time_s)?;
886        let assembly = self.assemble()?;
887        let cg_station_m = -assembly.mass_properties(time_s).cg_m.z;
888        let aero = AeroModel::new(&assembly.layout)?;
889        Ok(metrics::weakest_margin(&aero, mach, cg_station_m)?)
890    }
891
892    /// The builder's place, or [`Error::Order`] for a rocket read from a design.
893    fn build(&self) -> Result<&Build, Error> {
894        self.build
895            .as_ref()
896            .ok_or(Error::Order(Order::ReadFromDesign))
897    }
898
899    /// The builder's stage: its one, the first.
900    fn stage(&mut self) -> Result<&mut Stage, Error> {
901        self.design
902            .stages
903            .first_mut()
904            .ok_or(Error::Order(Order::ReadFromDesign))
905    }
906
907    /// A component of the stage, its id `kind` or, if that is taken, `kind-2`, `kind-3` and on.
908    fn component(
909        &self,
910        kind: &str,
911        name: &str,
912        part: Part,
913        position: Option<Position>,
914    ) -> Component {
915        let mut ids = Vec::new();
916        for stage in &self.design.stages {
917            for component in &stage.components {
918                ids.push(component.id.as_str());
919                ids.extend(component.children.iter().map(|child| child.id.as_str()));
920            }
921        }
922        let mut id = kind.to_owned();
923        let mut n = 1;
924        while ids.contains(&id.as_str()) {
925            n += 1;
926            id = format!("{kind}-{n}");
927        }
928        Component {
929            id,
930            name: name.to_owned(),
931            part,
932            position,
933            auto: Vec::new(),
934            motor_mount: None,
935            finish: None,
936            overrides: Overrides::default(),
937            overrides_include_children: false,
938            drag_override: None,
939            children: Vec::new(),
940        }
941    }
942
943    /// Adds a body component with aft radius `aft_radius_m`; a `tube` takes the attached parts
944    /// that follow.
945    fn push_body(
946        &mut self,
947        component: Component,
948        aft_radius_m: f64,
949        tube: bool,
950    ) -> Result<(), Error> {
951        let components = &mut self.stage()?.components;
952        components.push(component);
953        let index = components.len() - 1;
954        if let Some(build) = &mut self.build {
955            build.aft_radius_m = Some(aft_radius_m);
956            if tube {
957                build.tube = Some(index);
958            }
959        }
960        Ok(())
961    }
962
963    /// The last body tube's outer radius, m.
964    fn tube_radius_m(&self) -> Result<f64, Error> {
965        let index = self.build()?.tube.ok_or(Error::Order(Order::NoTube))?;
966        match self
967            .design
968            .stages
969            .first()
970            .and_then(|stage| stage.components.get(index))
971            .map(|component| &component.part)
972        {
973            Some(Part::BodyTube(tube)) => Ok(tube.outer_radius_m),
974            _ => Err(Error::Order(Order::NoTube)),
975        }
976    }
977
978    /// Attaches `component` to the last body tube.
979    fn attach(&mut self, component: Component) -> Result<(), Error> {
980        let index = self.build()?.tube.ok_or(Error::Order(Order::NoTube))?;
981        self.stage()?
982            .components
983            .get_mut(index)
984            .ok_or(Error::Order(Order::NoTube))?
985            .children
986            .push(component);
987        Ok(())
988    }
989}
990
991/// A wall's thickness, if it has one, checked finite and positive.
992fn check_wall(what: &'static str, wall: Wall) -> Result<(), Error> {
993    if let Wall::Shell { thickness_m } = wall {
994        positive(what, thickness_m)?;
995    }
996    Ok(())
997}
998
999/// A shoulder's length and wall, checked finite and positive, and, where its outer radius is
1000/// stated (zero is the tube's, found later), that radius too, with the wall no thicker than it.
1001/// `names` names those four numbers in an error: [`NOSE_SHOULDER`], [`TRANSITION_SHOULDER`].
1002fn check_shoulder(names: &[&'static str; 4], shoulder: &Shoulder) -> Result<(), Error> {
1003    let [length, wall, radius, past] = *names;
1004    positive(length, shoulder.length_m)?;
1005    positive(wall, shoulder.thickness_m)?;
1006    if shoulder.outer_radius_m != 0.0 {
1007        positive(radius, shoulder.outer_radius_m)?;
1008        if shoulder.thickness_m > shoulder.outer_radius_m {
1009            return Err(Error::Domain {
1010                what: past,
1011                value: shoulder.thickness_m,
1012            });
1013        }
1014    }
1015    Ok(())
1016}
1017
1018/// A nose shoulder's numbers, as [`check_shoulder`] names them.
1019const NOSE_SHOULDER: [&str; 4] = [
1020    "nose shoulder length, m",
1021    "nose shoulder wall, m",
1022    "nose shoulder radius, m",
1023    "nose shoulder wall past its radius, m",
1024];
1025
1026/// A transition shoulder's numbers, as [`check_shoulder`] names them.
1027const TRANSITION_SHOULDER: [&str; 4] = [
1028    "transition shoulder length, m",
1029    "transition shoulder wall, m",
1030    "transition shoulder radius, m",
1031    "transition shoulder wall past its radius, m",
1032];
1033
1034/// A position's offset or station, checked finite.
1035fn check_position(position: Position) -> Result<(), Error> {
1036    let (Position::Top {
1037        aft_offset_m: value,
1038    }
1039    | Position::Middle {
1040        aft_offset_m: value,
1041    }
1042    | Position::Bottom {
1043        aft_offset_m: value,
1044    }
1045    | Position::After {
1046        aft_offset_m: value,
1047    }
1048    | Position::Absolute { station_m: value }) = position;
1049    finite("position, m", value)?;
1050    Ok(())
1051}