Skip to main content

hpr/rocket/
catalog.rs

1//! Parts from a parts catalog, and the fittings they add to the builder.
2//!
3//! A catalog part ([`hpr_io::orc::Part`], such as one found in OpenRocket's catalog with
4//! [`hpr_io::orc::bundled`]) becomes a builder part with the `from_catalog` of the part it is:
5//!
6//! | Catalog kind | Builder part |
7//! |---|---|
8//! | nose cone | [`Nose::from_catalog`] |
9//! | body tube | [`Tube::from_catalog`], or [`MotorTube::from_catalog`] for a motor tube |
10//! | transition | [`Transition::from_catalog`] |
11//! | tube coupler, engine block, centering ring, bulkhead, launch lug, parachute, streamer | [`Fitting::from_catalog`] |
12//!
13//! Each takes the catalog's sizes, its material with the file's density, and its name, the
14//! maker and part number. A part that states its mass weighs that: its density is scaled so that
15//! the part as the catalog sizes it weighs the stated mass, the way OpenRocket gives a rigid
16//! part (anything but a parachute or streamer) its stated mass. The material's name says so. A
17//! part changed afterwards, cut shorter or given another shape, keeps that density, so its mass
18//! follows the change. A parachute's canopy and lines are scaled alike. OpenRocket overrides a
19//! parachute's mass instead, which comes to the same for a part left as it is, and leaves a
20//! streamer's stated mass unused; the one streamer in its catalog that states a mass weighs it
21//! here.
22//!
23//! A catalog leaves some dimensions unsaid. Each is chosen as OpenRocket 24.12 chooses it when
24//! it builds the part, as measured by `validation/oracles/openrocket/orc_built.py`, but for one.
25//! The shapes take these parameters ([`NoseShape`]):
26//!
27//! - an ogive is a tangent ogive (`radius_ratio` 1), a parabolic series a whole parabola (`K′`
28//!   1), a Haack series the von Kármán (`C` 0), and a power series has the exponent ½;
29//! - a transition's profile is clipped (cut from a whole nose cone, [`hpr_design::shapes`]) for
30//!   the elliptical, Haack and power series, and not for the others;
31//! - a filled part's shoulders are solid;
32//! - **a hollow part's shoulders have its wall**, or are solid where the wall is thicker than their
33//!   radius. OpenRocket gives them a wall of zero, so they weigh nothing; a molded nose cone's
34//!   shoulder is a tube of the same plastic.
35//!
36//! The builder refuses a part whose file names a material it doesn't define, for the part itself
37//! ([`CatalogProblem::UndefinedMaterial`]), where OpenRocket weighs it as zero. A parachute whose
38//! file names no line material, or one it doesn't define, has lines that weigh nothing, as in
39//! OpenRocket: there is no density to weigh them by. OpenRocket's catalog has eight such
40//! parachutes; six of them state their own mass, which they weigh, and two weigh their canopy
41//! alone.
42
43use hpr_design::{
44    BodyTube, CenteringRing, Density, InnerTube, LaunchLug, Material, NoseCone, NoseShape, Packing,
45    Parachute, Part, Position, Shoulder, Streamer, Wall,
46};
47use hpr_io::orc::{self, MaterialRef, PartKind, Shape};
48use serde::{Deserialize, Serialize};
49
50use super::{MotorTube, Nose, Transition, Tube};
51use crate::error::{CatalogProblem, Error};
52
53/// A part fitted to the last body tube: a coupler, a centering ring, a bulkhead, a launch lug,
54/// or a packed parachute or streamer. It sits flush with the tube's aft end unless placed
55/// elsewhere with [`Fitting::at`].
56///
57/// A coupler, a ring or a bulkhead goes inside the tube; a launch lug on its outside. A
58/// parachute or a streamer from a catalog is its mass, packed into a point; its drag is a
59/// recovery device ([`super::Rocket::add_parachute`]).
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct Fitting {
62    part: Part,
63    position: Position,
64    name: String,
65}
66
67impl Fitting {
68    fn new(part: Part) -> Self {
69        Self {
70            part,
71            position: Position::Bottom { aft_offset_m: 0.0 },
72            name: String::new(),
73        }
74    }
75
76    /// A tube inside the body tube, `length_m` long, `outer_diameter_m` across, with a wall
77    /// `wall_m` thick: a coupler joining two tubes, or an engine block.
78    #[must_use]
79    pub fn coupler(length_m: f64, outer_diameter_m: f64, wall_m: f64, material: Material) -> Self {
80        Self::new(Part::InnerTube(InnerTube {
81            length_m,
82            outer_radius_m: 0.5 * outer_diameter_m,
83            thickness_m: wall_m,
84            radial_offset_m: 0.0,
85            angle_rad: 0.0,
86            material,
87            cluster_m: Vec::new(),
88        }))
89    }
90
91    /// A centering ring `length_m` thick, `outer_diameter_m` across with a hole
92    /// `inner_diameter_m` across.
93    #[must_use]
94    pub fn centering_ring(
95        length_m: f64,
96        outer_diameter_m: f64,
97        inner_diameter_m: f64,
98        material: Material,
99    ) -> Self {
100        Self::new(Part::CenteringRing(CenteringRing {
101            length_m,
102            outer_radius_m: 0.5 * outer_diameter_m,
103            inner_radius_m: 0.5 * inner_diameter_m,
104            material,
105        }))
106    }
107
108    /// A bulkhead: a solid disc `length_m` thick and `diameter_m` across.
109    #[must_use]
110    pub fn bulkhead(length_m: f64, diameter_m: f64, material: Material) -> Self {
111        Self::new(Part::CenteringRing(CenteringRing::bulkhead(
112            length_m,
113            0.5 * diameter_m,
114            material,
115        )))
116    }
117
118    /// A launch lug on the outside of the tube, `length_m` long, `outer_diameter_m` across, with
119    /// a wall `wall_m` thick.
120    #[must_use]
121    pub fn launch_lug(
122        length_m: f64,
123        outer_diameter_m: f64,
124        wall_m: f64,
125        material: Material,
126    ) -> Self {
127        Self::new(Part::LaunchLug(LaunchLug {
128            length_m,
129            outer_radius_m: 0.5 * outer_diameter_m,
130            thickness_m: wall_m,
131            angle_rad: 0.0,
132            count: 1,
133            spacing_m: 0.0,
134            material,
135        }))
136    }
137
138    /// The catalog part `part`, a tube coupler, engine block, centering ring, bulkhead, launch
139    /// lug, parachute or streamer, as the [module](self) says, named by its maker and number.
140    /// An engine block is a coupler; a parachute's or streamer's packing is a point.
141    ///
142    /// # Errors
143    ///
144    /// [`Error::Catalog`]: [`CatalogProblem::Kind`] for a part of another kind,
145    /// [`CatalogProblem::UndefinedMaterial`] for one whose material its file doesn't define,
146    /// [`CatalogProblem::NoVolume`] for one that states a mass but has no volume to hold it;
147    /// [`Error::Domain`] for a stated mass that is negative or not finite; and [`Error::Design`]
148    /// for one that states a mass but whose sizes can't be weighed, such as a bore no narrower
149    /// than its outside (a part that states no mass is refused for that when it is added).
150    pub fn from_catalog(part: &orc::Part) -> Result<Self, Error> {
151        let built = match &part.kind {
152            PartKind::TubeCoupler(tube) | PartKind::EngineBlock(tube) => Self::coupler(
153                tube.length_m,
154                tube.outer_diameter_m,
155                tube.thickness_m(),
156                material(part, &tube.material)?,
157            ),
158            PartKind::CenteringRing(tube) => Self::centering_ring(
159                tube.length_m,
160                tube.outer_diameter_m,
161                tube.inner_diameter_m,
162                material(part, &tube.material)?,
163            ),
164            PartKind::Bulkhead(bulkhead) => Self::bulkhead(
165                bulkhead.length_m,
166                bulkhead.outer_diameter_m,
167                material(part, &bulkhead.material)?,
168            ),
169            PartKind::LaunchLug(tube) => Self::launch_lug(
170                tube.length_m,
171                tube.outer_diameter_m,
172                tube.thickness_m(),
173                material(part, &tube.material)?,
174            ),
175            PartKind::Parachute(chute) => {
176                // Lines of no material, or of one the file doesn't define, weigh nothing.
177                let line_material = chute
178                    .line_material
179                    .as_ref()
180                    .and_then(MaterialRef::material)
181                    .unwrap_or_else(|| Material::line("none defined", 0.0));
182                Self::new(Part::Parachute(Parachute {
183                    diameter_m: chute.diameter_m,
184                    canopy_material: material(part, &chute.material)?,
185                    line_count: chute.line_count,
186                    line_length_m: chute.line_length_m,
187                    line_material,
188                    packing: POINT,
189                }))
190            }
191            PartKind::Streamer(streamer) => Self::new(Part::Streamer(Streamer {
192                length_m: streamer.length_m,
193                width_m: streamer.width_m,
194                material: material(part, &streamer.material)?,
195                packing: POINT,
196            })),
197            _ => return Err(wrong_kind(part, "Fitting::from_catalog")),
198        };
199        let mut fitting = Self {
200            name: label(part),
201            ..built
202        };
203        if let Some(factor) = stated(part, &fitting.part)? {
204            match &mut fitting.part {
205                Part::InnerTube(InnerTube { material, .. })
206                | Part::CenteringRing(CenteringRing { material, .. })
207                | Part::LaunchLug(LaunchLug { material, .. })
208                | Part::Streamer(Streamer { material, .. }) => scale(material, factor),
209                Part::Parachute(chute) => {
210                    scale(&mut chute.canopy_material, factor);
211                    scale(&mut chute.line_material, factor);
212                }
213                // Not made above.
214                _ => {}
215            }
216        }
217        Ok(fitting)
218    }
219
220    /// The same fitting at `position` along the body tube it is on.
221    #[must_use]
222    pub fn at(mut self, position: Position) -> Self {
223        self.position = position;
224        self
225    }
226
227    /// The same fitting with `name`, which the design file keeps.
228    #[must_use]
229    pub fn named(mut self, name: &str) -> Self {
230        name.clone_into(&mut self.name);
231        self
232    }
233
234    /// The design's component id for the fitting's kind, before any number added to make it
235    /// unique; `None` for a part that isn't a fitting (one read from a file).
236    pub(super) fn id(&self) -> Option<&'static str> {
237        Some(match &self.part {
238            Part::InnerTube(_) => "coupler",
239            Part::CenteringRing(ring) if ring.inner_radius_m == 0.0 => "bulkhead",
240            Part::CenteringRing(_) => "ring",
241            Part::LaunchLug(_) => "launch-lug",
242            Part::Parachute(_) => "parachute",
243            Part::Streamer(_) => "streamer",
244            _ => return None,
245        })
246    }
247
248    /// Its parts: the design's part, where it sits, and its name.
249    pub(super) fn into_parts(self) -> (Part, Position, String) {
250        (self.part, self.position, self.name)
251    }
252}
253
254/// A packing of no size: a point at the fitting's position.
255const POINT: Packing = Packing {
256    length_m: 0.0,
257    radius_m: 0.0,
258    radial_offset_m: 0.0,
259    angle_rad: 0.0,
260};
261
262impl Nose {
263    /// The catalog nose cone `part`, as the [module](self) says: its base its own diameter, its
264    /// shoulder (if any) its own radius, named by its maker and number.
265    ///
266    /// # Errors
267    ///
268    /// [`Error::Catalog`]: [`CatalogProblem::Kind`] for a part of another kind,
269    /// [`CatalogProblem::UndefinedMaterial`] for one whose material its file doesn't define,
270    /// [`CatalogProblem::NoWall`] for one neither filled nor given a wall,
271    /// [`CatalogProblem::Shape`] for a shape the builder doesn't know,
272    /// [`CatalogProblem::NoVolume`] for one that states a mass but has no volume to hold it;
273    /// [`Error::Domain`] for a stated mass that is negative or not finite; and [`Error::Design`]
274    /// for one that states a mass but whose sizes can't be weighed, such as a bore no narrower
275    /// than its outside (a part that states no mass is refused for that when it is added).
276    pub fn from_catalog(part: &orc::Part) -> Result<Self, Error> {
277        let PartKind::NoseCone(nose) = &part.kind else {
278            return Err(wrong_kind(part, "Nose::from_catalog"));
279        };
280        let cone = NoseCone {
281            shape: shape(part, nose.shape)?,
282            length_m: nose.length_m,
283            base_radius_m: 0.5 * nose.outer_diameter_m,
284            wall: wall(part, nose.filled, nose.thickness_m)?,
285            shoulder: None,
286            material: material(part, &nose.material)?,
287        };
288        let cone = NoseCone {
289            shoulder: shoulder(nose.shoulder_diameter_m, nose.shoulder_length_m, cone.wall),
290            ..cone
291        };
292        let mut material = cone.material.clone();
293        if let Some(factor) = stated(part, &Part::NoseCone(cone.clone()))? {
294            scale(&mut material, factor);
295        }
296        Ok(Self {
297            shape: cone.shape,
298            length_m: cone.length_m,
299            wall: cone.wall,
300            shoulder: cone.shoulder,
301            material,
302            name: label(part),
303            diameter_m: Some(nose.outer_diameter_m),
304        })
305    }
306}
307
308impl Tube {
309    /// The catalog body tube `part`: its length, outer diameter and wall, named by its maker
310    /// and number. A catalog tube is often sold longer than it is flown:
311    /// [`Tube::with_length_m`] cuts it.
312    ///
313    /// # Errors
314    ///
315    /// [`Error::Catalog`]: [`CatalogProblem::Kind`] for a part of another kind,
316    /// [`CatalogProblem::UndefinedMaterial`] for one whose material its file doesn't define,
317    /// [`CatalogProblem::NoVolume`] for one that states a mass but has no volume to hold it;
318    /// [`Error::Domain`] for a stated mass that is negative or not finite; and [`Error::Design`]
319    /// for one that states a mass but whose sizes can't be weighed, such as a bore no narrower
320    /// than its outside (a part that states no mass is refused for that when it is added).
321    pub fn from_catalog(part: &orc::Part) -> Result<Self, Error> {
322        let PartKind::BodyTube(tube) = &part.kind else {
323            return Err(wrong_kind(part, "Tube::from_catalog"));
324        };
325        Ok(Self {
326            name: label(part),
327            ..Self::new(
328                tube.length_m,
329                tube.thickness_m(),
330                tube_material(part, tube)?,
331            )
332            .with_diameter_m(tube.outer_diameter_m)
333        })
334    }
335
336    /// The same tube cut to `length_m`. A catalog tube that states its mass keeps the density
337    /// that gives it that mass, so a cut weighs its share.
338    #[must_use]
339    pub fn with_length_m(mut self, length_m: f64) -> Self {
340        self.length_m = length_m;
341        self
342    }
343}
344
345impl MotorTube {
346    /// The catalog body tube `part` as the motor tube: its length, bore and wall, named by its
347    /// maker and number. A catalog tube is often sold longer than it is flown:
348    /// [`MotorTube::with_length_m`] cuts it.
349    ///
350    /// # Errors
351    ///
352    /// [`Error::Catalog`]: [`CatalogProblem::Kind`] for a part of another kind,
353    /// [`CatalogProblem::UndefinedMaterial`] for one whose material its file doesn't define,
354    /// [`CatalogProblem::NoVolume`] for one that states a mass but has no volume to hold it;
355    /// [`Error::Domain`] for a stated mass that is negative or not finite; and [`Error::Design`]
356    /// for one that states a mass but whose sizes can't be weighed, such as a bore no narrower
357    /// than its outside (a part that states no mass is refused for that when it is added).
358    pub fn from_catalog(part: &orc::Part) -> Result<Self, Error> {
359        let PartKind::BodyTube(tube) = &part.kind else {
360            return Err(wrong_kind(part, "MotorTube::from_catalog"));
361        };
362        Ok(Self {
363            name: label(part),
364            ..Self::new(
365                tube.length_m,
366                tube.inner_diameter_m,
367                tube.thickness_m(),
368                tube_material(part, tube)?,
369            )
370        })
371    }
372
373    /// The same tube cut to `length_m`. A catalog tube that states its mass keeps the density
374    /// that gives it that mass, so a cut weighs its share.
375    #[must_use]
376    pub fn with_length_m(mut self, length_m: f64) -> Self {
377        self.length_m = length_m;
378        self
379    }
380}
381
382impl Transition {
383    /// The catalog transition `part`, as the [module](self) says: both ends their own
384    /// diameters, each shoulder (if any) its own radius, named by its maker and number.
385    ///
386    /// # Errors
387    ///
388    /// [`Error::Catalog`]: [`CatalogProblem::Kind`] for a part of another kind,
389    /// [`CatalogProblem::UndefinedMaterial`] for one whose material its file doesn't define,
390    /// [`CatalogProblem::NoWall`] for one neither filled nor given a wall,
391    /// [`CatalogProblem::Shape`] for a shape the builder doesn't know,
392    /// [`CatalogProblem::NoVolume`] for one that states a mass but has no volume to hold it;
393    /// [`Error::Domain`] for a stated mass that is negative or not finite; and [`Error::Design`]
394    /// for one that states a mass but whose sizes can't be weighed, such as a bore no narrower
395    /// than its outside (a part that states no mass is refused for that when it is added).
396    pub fn from_catalog(part: &orc::Part) -> Result<Self, Error> {
397        let PartKind::Transition(transition) = &part.kind else {
398            return Err(wrong_kind(part, "Transition::from_catalog"));
399        };
400        let wall = wall(part, transition.filled, transition.thickness_m)?;
401        let piece = hpr_design::Transition {
402            shape: shape(part, transition.shape)?,
403            clipped: clipped(transition.shape),
404            length_m: transition.length_m,
405            fore_radius_m: 0.5 * transition.fore_outer_diameter_m,
406            aft_radius_m: 0.5 * transition.aft_outer_diameter_m,
407            wall,
408            fore_shoulder: shoulder(
409                transition.fore_shoulder_diameter_m,
410                transition.fore_shoulder_length_m,
411                wall,
412            ),
413            aft_shoulder: shoulder(
414                transition.aft_shoulder_diameter_m,
415                transition.aft_shoulder_length_m,
416                wall,
417            ),
418            material: material(part, &transition.material)?,
419        };
420        let mut material = piece.material.clone();
421        if let Some(factor) = stated(part, &Part::Transition(piece.clone()))? {
422            scale(&mut material, factor);
423        }
424        Ok(Self {
425            shape: piece.shape,
426            length_m: piece.length_m,
427            aft_diameter_m: transition.aft_outer_diameter_m,
428            wall: piece.wall,
429            material,
430            name: label(part),
431            fore_diameter_m: Some(transition.fore_outer_diameter_m),
432            clipped: piece.clipped,
433            fore_shoulder: piece.fore_shoulder,
434            aft_shoulder: piece.aft_shoulder,
435        })
436    }
437}
438
439/// The factor that gives `design`, the part `part` makes, the mass `part` states, if it states
440/// one: the stated mass over the part's mass at the file's densities, by which every density of
441/// the part is scaled.
442fn stated(part: &orc::Part, design: &Part) -> Result<Option<f64>, Error> {
443    let Some(stated_kg) = part.mass_kg else {
444        return Ok(None);
445    };
446    if !(stated_kg.is_finite() && stated_kg >= 0.0) {
447        return Err(Error::Domain {
448            what: "catalog part's stated mass, kg",
449            value: stated_kg,
450        });
451    }
452    // The body radius only places a launch lug; its mass is the same on any tube.
453    let weighed_kg = design.mass_properties(Some(1.0))?.mass_kg;
454    let factor = stated_kg / weighed_kg;
455    if weighed_kg > 0.0 && weighed_kg.is_finite() && factor.is_finite() {
456        Ok(Some(factor))
457    } else {
458        Err(catalog_error(part, CatalogProblem::NoVolume))
459    }
460}
461
462/// `material` with its density scaled by `factor`, named for why.
463fn scale(material: &mut Material, factor: f64) {
464    // A weightless material (a parachute's lines of no density) stays as it is, and as named.
465    if matches!(
466        material.density,
467        Density::Bulk { kg_m3: 0.0 }
468            | Density::Surface { kg_m2: 0.0 }
469            | Density::Line { kg_m: 0.0 }
470    ) {
471        return;
472    }
473    material.density = match material.density {
474        Density::Bulk { kg_m3 } => Density::Bulk {
475            kg_m3: kg_m3 * factor,
476        },
477        Density::Surface { kg_m2 } => Density::Surface {
478            kg_m2: kg_m2 * factor,
479        },
480        Density::Line { kg_m } => Density::Line {
481            kg_m: kg_m * factor,
482        },
483    };
484    material
485        .name
486        .push_str(", density set by the part's stated mass");
487}
488
489/// A catalog body tube's material, its density scaled to the tube's stated mass if it states
490/// one.
491fn tube_material(part: &orc::Part, tube: &orc::Tube) -> Result<Material, Error> {
492    let mut material = material(part, &tube.material)?;
493    let body = Part::BodyTube(BodyTube {
494        length_m: tube.length_m,
495        outer_radius_m: 0.5 * tube.outer_diameter_m,
496        thickness_m: tube.thickness_m(),
497        material: material.clone(),
498    });
499    if let Some(factor) = stated(part, &body)? {
500        scale(&mut material, factor);
501    }
502    Ok(material)
503}
504
505/// A catalog part's name in the design: its maker and part number.
506fn label(part: &orc::Part) -> String {
507    format!("{} {}", part.manufacturer, part.part_number)
508}
509
510/// The refusal of a part of a kind `builder` doesn't make.
511fn wrong_kind(part: &orc::Part, builder: &'static str) -> Error {
512    let found = match &part.kind {
513        PartKind::BodyTube(_) => "body tube",
514        PartKind::TubeCoupler(_) => "tube coupler",
515        PartKind::EngineBlock(_) => "engine block",
516        PartKind::CenteringRing(_) => "centering ring",
517        PartKind::LaunchLug(_) => "launch lug",
518        PartKind::Bulkhead(_) => "bulkhead",
519        PartKind::NoseCone(_) => "nose cone",
520        PartKind::Transition(_) => "transition",
521        PartKind::Parachute(_) => "parachute",
522        PartKind::Streamer(_) => "streamer",
523        _ => "part of a kind the builder doesn't know",
524    };
525    catalog_error(part, CatalogProblem::Kind { found, builder })
526}
527
528fn catalog_error(part: &orc::Part, problem: CatalogProblem) -> Error {
529    Error::Catalog {
530        part: format!("{} ({})", label(part), part.file),
531        problem,
532    }
533}
534
535/// The part's material with its file's density.
536fn material(part: &orc::Part, material: &MaterialRef) -> Result<Material, Error> {
537    material.material().ok_or_else(|| {
538        catalog_error(
539            part,
540            CatalogProblem::UndefinedMaterial(material.name.clone()),
541        )
542    })
543}
544
545/// A nose cone's or transition's wall: solid if the file says it is filled, else the wall it
546/// gives.
547fn wall(part: &orc::Part, filled: Option<bool>, thickness_m: Option<f64>) -> Result<Wall, Error> {
548    match (filled, thickness_m) {
549        (Some(true), _) => Ok(Wall::Filled {}),
550        (_, Some(thickness_m)) => Ok(Wall::Shell { thickness_m }),
551        (_, None) => Err(catalog_error(part, CatalogProblem::NoWall)),
552    }
553}
554
555/// A shoulder `diameter_m` across and `length_m` long, if it has both, open at its end: solid in
556/// a filled part, its part's wall in a hollow one, solid where that wall is thicker than its
557/// radius.
558fn shoulder(diameter_m: f64, length_m: f64, wall: Wall) -> Option<Shoulder> {
559    if !(diameter_m > 0.0 && length_m > 0.0) {
560        return None;
561    }
562    let radius_m = 0.5 * diameter_m;
563    let thickness_m = match wall {
564        Wall::Filled {} => radius_m,
565        Wall::Shell { thickness_m } => thickness_m.min(radius_m),
566    };
567    Some(Shoulder {
568        length_m,
569        outer_radius_m: radius_m,
570        thickness_m,
571        capped: false,
572    })
573}
574
575/// The profile OpenRocket gives a catalog shape, whose file gives no parameter.
576fn shape(part: &orc::Part, shape: Shape) -> Result<NoseShape, Error> {
577    Ok(match shape {
578        Shape::Conical => NoseShape::Conical {},
579        Shape::Ogive => NoseShape::TANGENT_OGIVE,
580        Shape::Ellipsoid => NoseShape::Elliptical {},
581        Shape::Parabolic => NoseShape::ParabolicSeries { parameter: 1.0 },
582        Shape::Haack => NoseShape::VON_KARMAN,
583        Shape::Power => NoseShape::PowerSeries { exponent: 0.5 },
584        _ => return Err(catalog_error(part, CatalogProblem::Shape)),
585    })
586}
587
588/// Whether OpenRocket clips a catalog transition of `shape`.
589fn clipped(shape: Shape) -> bool {
590    matches!(shape, Shape::Ellipsoid | Shape::Haack | Shape::Power)
591}
592
593#[cfg(test)]
594mod tests {
595    #![allow(
596        clippy::unwrap_used,
597        clippy::expect_used,
598        clippy::panic,
599        reason = "tests state their expectations by unwrapping and panicking"
600    )]
601
602    use hpr_design::DesignError;
603    use hpr_io::orc::bundled;
604
605    use super::*;
606    use crate::rocket::material;
607    use crate::{Order, Rocket};
608
609    /// The one bundled part `maker` numbers `number`.
610    fn find(maker: &str, number: &str) -> &'static orc::Part {
611        let found = bundled().find(maker, number);
612        assert_eq!(found.len(), 1, "{maker} {number}");
613        found[0]
614    }
615
616    /// The rocket's laid-out component `id`: its mass, kg.
617    fn mass_kg(rocket: &Rocket, id: &str) -> f64 {
618        let layout = rocket.design().layout().unwrap();
619        layout.find(id).unwrap().1.own.mass_kg
620    }
621
622    #[test]
623    fn a_cut_tube_keeps_its_stated_mass_per_length() {
624        let part = bundled()
625            .parts
626            .iter()
627            .find(|part| matches!(part.kind, PartKind::BodyTube(_)) && part.mass_kg.is_some())
628            .expect("a body tube that states its mass");
629        let stated_kg = part.mass_kg.unwrap();
630        let tube = Tube::from_catalog(part).unwrap();
631        assert!(
632            tube.material
633                .name
634                .ends_with(", density set by the part's stated mass")
635        );
636        let quarter_m = 0.25 * tube.length_m;
637        let mut whole = Rocket::new("whole", 0.1).unwrap();
638        whole.add_tube(tube.clone()).unwrap();
639        let mut cut = Rocket::new("cut", 0.1).unwrap();
640        cut.add_tube(tube.with_length_m(quarter_m))
641            .unwrap()
642            .add_motor_tube(
643                MotorTube::from_catalog(part)
644                    .unwrap()
645                    .with_length_m(quarter_m),
646            )
647            .unwrap();
648        assert!((mass_kg(&whole, "tube") - stated_kg).abs() <= 1e-14 * stated_kg);
649        assert!((mass_kg(&cut, "tube") - 0.25 * stated_kg).abs() <= 1e-14 * stated_kg);
650        // As the motor tube, the same annulus: the same share.
651        assert!((mass_kg(&cut, "motor-tube") - 0.25 * stated_kg).abs() <= 1e-14 * stated_kg);
652
653        // A tube that states no mass keeps its file's density, cut or not.
654        let plain = find("LOC Precision", "BT-2.56");
655        let PartKind::BodyTube(sizes) = &plain.kind else {
656            panic!("a body tube");
657        };
658        let mut rocket = Rocket::new("plain", 0.1).unwrap();
659        rocket
660            .add_tube(Tube::from_catalog(plain).unwrap().with_length_m(0.3))
661            .unwrap();
662        let annulus_kg = std::f64::consts::PI / 4.0
663            * (sizes.outer_diameter_m.powi(2) - sizes.inner_diameter_m.powi(2))
664            * 0.3
665            * sizes.material.density.unwrap();
666        assert!((mass_kg(&rocket, "tube") - annulus_kg).abs() <= 1e-14 * annulus_kg);
667    }
668
669    #[test]
670    fn a_stated_mass_is_a_density_that_follows_changes() {
671        // A filled nose stating its mass weighs it as the catalog sizes it...
672        let part = find("SEMROC", "BNC-5RA");
673        let stated_kg = part.mass_kg.expect("BNC-5RA states its mass");
674        let nose = Nose::from_catalog(part).unwrap();
675        let mut rocket = Rocket::new("stated", 0.1).unwrap();
676        rocket.add_nose(nose.clone()).unwrap();
677        assert!((mass_kg(&rocket, "nose") - stated_kg).abs() <= 1e-14 * stated_kg);
678        // ...and more with a longer shoulder, its density kept, where a mass override would
679        // have kept the old mass.
680        let longer = Nose {
681            shoulder: nose.shoulder.map(|shoulder| Shoulder {
682                length_m: 2.0 * shoulder.length_m,
683                ..shoulder
684            }),
685            ..nose
686        };
687        let mut rocket = Rocket::new("longer", 0.1).unwrap();
688        rocket.add_nose(longer).unwrap();
689        assert!(mass_kg(&rocket, "nose") > 1.01 * stated_kg);
690
691        // A tube stating its mass, made wider, weighs more.
692        let part = bundled()
693            .parts
694            .iter()
695            .find(|part| matches!(part.kind, PartKind::BodyTube(_)) && part.mass_kg.is_some())
696            .expect("a body tube that states its mass");
697        let tube = Tube::from_catalog(part).unwrap();
698        let wider = 2.0 * tube.diameter_m.unwrap();
699        let mut rocket = Rocket::new("wider", 0.1).unwrap();
700        rocket.add_tube(tube.with_diameter_m(wider)).unwrap();
701        assert!(mass_kg(&rocket, "tube") > 1.5 * part.mass_kg.unwrap());
702
703        // A parachute's canopy and lines are scaled alike.
704        let chute = bundled()
705            .parts
706            .iter()
707            .find(|part| {
708                matches!(&part.kind, PartKind::Parachute(chute) if chute.line_material.is_some())
709                    && part.mass_kg.is_some()
710            })
711            .expect("a parachute with lines that states its mass");
712        let fitting = Fitting::from_catalog(chute).unwrap();
713        let Part::Parachute(built) = &fitting.part else {
714            panic!("a parachute");
715        };
716        let PartKind::Parachute(listed) = &chute.kind else {
717            panic!("a parachute");
718        };
719        let ratio = |material: &Material, listed: &MaterialRef| match material.density {
720            Density::Surface { kg_m2 } => kg_m2 / listed.density.unwrap(),
721            Density::Line { kg_m } => kg_m / listed.density.unwrap(),
722            Density::Bulk { .. } => panic!("no bulk material in a parachute"),
723        };
724        let canopy = ratio(&built.canopy_material, &listed.material);
725        let lines = ratio(&built.line_material, listed.line_material.as_ref().unwrap());
726        assert!((canopy - lines).abs() <= 1e-15 * canopy);
727        let stated_kg = chute.mass_kg.unwrap();
728        assert!((built.mass_kg().unwrap() - stated_kg).abs() <= 1e-14 * stated_kg);
729    }
730
731    #[test]
732    fn a_catalog_nose_sets_the_diameter_behind_it() {
733        let nose = find("LOC Precision", "PNC-2.56");
734        let PartKind::NoseCone(cone) = &nose.kind else {
735            panic!("a nose cone");
736        };
737        let mut rocket = Rocket::new("nose", 0.5).unwrap();
738        rocket
739            .add_nose(Nose::from_catalog(nose).unwrap())
740            .unwrap()
741            .add_tube(Tube::new(0.3, 0.001, material("kraft_phenolic").unwrap()))
742            .unwrap();
743        let layout = rocket.design().layout().unwrap();
744        let Part::BodyTube(tube) = &layout.find("tube").unwrap().1.part else {
745            panic!("a body tube");
746        };
747        assert_eq!(tube.outer_radius_m, 0.5 * cone.outer_diameter_m);
748        // The rocket's diameter, 0.5 m, is nowhere: the reference is the largest body's.
749        assert_eq!(layout.reference_diameter_m, cone.outer_diameter_m);
750        assert_eq!(
751            layout
752                .find("nose")
753                .map(|(_, nose)| nose.part.clone())
754                .and_then(|part| match part {
755                    Part::NoseCone(cone) => Some(cone.base_radius_m),
756                    _ => None,
757                }),
758            Some(0.5 * cone.outer_diameter_m)
759        );
760    }
761
762    #[test]
763    fn shoulders_are_solid_or_the_parts_wall() {
764        // A hollow nose: its shoulder has its wall, not OpenRocket's zero.
765        let hollow = Nose::from_catalog(find("LOC Precision", "PNC-2.56")).unwrap();
766        let Wall::Shell { thickness_m } = hollow.wall else {
767            panic!("PNC-2.56 is hollow");
768        };
769        let shoulder = hollow.shoulder.unwrap();
770        assert_eq!(shoulder.thickness_m, thickness_m);
771        assert_eq!(shoulder.outer_radius_m, 0.5 * 0.06477);
772        // A filled one: solid.
773        let filled = Nose::from_catalog(find("SEMROC", "BNC-5RA")).unwrap();
774        assert_eq!(filled.wall, Wall::Filled {});
775        let shoulder = filled.shoulder.unwrap();
776        assert_eq!(shoulder.thickness_m, shoulder.outer_radius_m);
777        // A wall thicker than the shoulder's radius leaves it solid.
778        let thick = shoulder_of(0.01, 0.02, Wall::Shell { thickness_m: 0.007 });
779        assert_eq!(thick.map(|s| s.thickness_m), Some(0.005));
780        // No length or no diameter, no shoulder.
781        assert_eq!(shoulder_of(0.01, 0.0, Wall::Filled {}), None);
782        assert_eq!(shoulder_of(0.0, 0.02, Wall::Filled {}), None);
783    }
784
785    fn shoulder_of(diameter_m: f64, length_m: f64, wall: Wall) -> Option<Shoulder> {
786        shoulder(diameter_m, length_m, wall)
787    }
788
789    #[test]
790    fn a_part_naming_an_undefined_material_is_refused() {
791        let part = find("SEMROC", "BNC-5NAS");
792        let Err(Error::Catalog {
793            part: named,
794            problem,
795        }) = Nose::from_catalog(part)
796        else {
797            panic!("its material is undefined");
798        };
799        assert_eq!(
800            problem,
801            CatalogProblem::UndefinedMaterial("Balsa, bulk, Estes typical".to_owned())
802        );
803        assert_eq!(named, "SEMROC BNC-5NAS (semroc.orc)");
804    }
805
806    #[test]
807    fn fittings_go_on_a_tube_and_need_a_bore() {
808        let ring = find("LOC Precision", "CR-2.56-38mm");
809        let mut rocket = Rocket::new("fittings", 0.0668).unwrap();
810        let Err(Error::Order(Order::NoTube)) =
811            rocket.add_fitting(Fitting::from_catalog(ring).unwrap())
812        else {
813            panic!("a fitting goes on a tube");
814        };
815        rocket
816            .add_tube(Tube::from_catalog(find("LOC Precision", "BT-2.56")).unwrap())
817            .unwrap();
818        let wood = material("birch_plywood").unwrap();
819        // A ring whose hole is as wide as it is: refused where it is added, and no part added.
820        let Err(Error::Design(DesignError::Geometry(_) | DesignError::Domain { .. })) =
821            rocket.add_fitting(Fitting::centering_ring(0.003, 0.06, 0.06, wood.clone()))
822        else {
823            panic!("a ring needs a bore narrower than its outside");
824        };
825        assert!(rocket.design().stages[0].components[0].children.is_empty());
826        rocket
827            .add_fitting(Fitting::from_catalog(ring).unwrap())
828            .unwrap()
829            .add_fitting(Fitting::bulkhead(0.006, 0.064, wood.clone()))
830            .unwrap()
831            .add_fitting(Fitting::coupler(0.15, 0.0649, 0.001, wood.clone()))
832            .unwrap()
833            .add_fitting(Fitting::launch_lug(0.05, 0.008, 0.0005, wood))
834            .unwrap()
835            .add_fitting(Fitting::from_catalog(find("LOC Precision", "LP-36-2022")).unwrap())
836            .unwrap()
837            .add_fitting(Fitting::from_catalog(find("LOC Precision", "CR-2.56-38mm")).unwrap())
838            .unwrap();
839        let ids: Vec<_> = rocket.design().stages[0].components[0]
840            .children
841            .iter()
842            .map(|child| child.id.as_str())
843            .collect();
844        assert_eq!(
845            ids,
846            [
847                "ring",
848                "bulkhead",
849                "coupler",
850                "launch-lug",
851                "parachute",
852                "ring-2"
853            ]
854        );
855        // The catalog's ring weighs its plywood annulus.
856        let PartKind::CenteringRing(tube) = &ring.kind else {
857            panic!("a ring");
858        };
859        let annulus_kg = std::f64::consts::PI / 4.0
860            * (tube.outer_diameter_m.powi(2) - tube.inner_diameter_m.powi(2))
861            * tube.length_m
862            * tube.material.density.unwrap();
863        assert!((mass_kg(&rocket, "ring") - annulus_kg).abs() <= 1e-15 * annulus_kg);
864    }
865
866    #[test]
867    fn a_fitting_read_with_another_part_is_refused() {
868        let wood = material("birch_plywood").unwrap();
869        let mut value = serde_json::to_value(Fitting::bulkhead(0.006, 0.06, wood.clone())).unwrap();
870        value["part"] = serde_json::to_value(Part::BodyTube(BodyTube {
871            length_m: 0.1,
872            outer_radius_m: 0.03,
873            thickness_m: 0.001,
874            material: wood,
875        }))
876        .unwrap();
877        let fitting: Fitting = serde_json::from_value(value).unwrap();
878        let mut rocket = Rocket::new("read", 0.07).unwrap();
879        rocket
880            .add_tube(Tube::new(0.3, 0.001, material("kraft_phenolic").unwrap()))
881            .unwrap();
882        let Err(Error::Order(Order::NotAFitting)) = rocket.add_fitting(fitting) else {
883            panic!("a body tube isn't a fitting");
884        };
885        assert!(rocket.design().stages[0].components[0].children.is_empty());
886    }
887
888    #[test]
889    fn shapes_take_openrockets_parameters() {
890        let part = find("LOC Precision", "PNC-2.56");
891        let cases = [
892            (Shape::Conical, NoseShape::Conical {}, false),
893            (Shape::Ogive, NoseShape::Ogive { radius_ratio: 1.0 }, false),
894            (Shape::Ellipsoid, NoseShape::Elliptical {}, true),
895            (
896                Shape::Parabolic,
897                NoseShape::ParabolicSeries { parameter: 1.0 },
898                false,
899            ),
900            (Shape::Haack, NoseShape::Haack { parameter: 0.0 }, true),
901            (Shape::Power, NoseShape::PowerSeries { exponent: 0.5 }, true),
902        ];
903        for (catalog, ours, clips) in cases {
904            assert_eq!(shape(part, catalog).unwrap(), ours, "{catalog:?}");
905            assert_eq!(clipped(catalog), clips, "{catalog:?}");
906        }
907    }
908
909    #[test]
910    fn a_part_of_another_kind_names_both() {
911        let chute = find("LOC Precision", "LP-36-2022");
912        for (result, builder) in [
913            (Nose::from_catalog(chute).err(), "Nose::from_catalog"),
914            (Tube::from_catalog(chute).err(), "Tube::from_catalog"),
915            (
916                MotorTube::from_catalog(chute).err(),
917                "MotorTube::from_catalog",
918            ),
919            (
920                Transition::from_catalog(chute).err(),
921                "Transition::from_catalog",
922            ),
923        ] {
924            let Some(Error::Catalog {
925                problem:
926                    CatalogProblem::Kind {
927                        found,
928                        builder: named,
929                    },
930                ..
931            }) = result
932            else {
933                panic!("{builder} takes no parachute");
934            };
935            assert_eq!((found, named), ("parachute", builder));
936        }
937        let nose = find("LOC Precision", "PNC-2.56");
938        let Err(Error::Catalog {
939            problem: CatalogProblem::Kind { found, builder },
940            ..
941        }) = Fitting::from_catalog(nose)
942        else {
943            panic!("a nose cone isn't a fitting");
944        };
945        assert_eq!((found, builder), ("nose cone", "Fitting::from_catalog"));
946    }
947
948    #[test]
949    fn a_stated_mass_needs_a_volume_and_a_number() {
950        let tube = bundled()
951            .parts
952            .iter()
953            .find(|part| matches!(part.kind, PartKind::BodyTube(_)) && part.mass_kg.is_some())
954            .expect("a body tube that states its mass");
955        // A material of no density gives the part no mass to scale.
956        let mut weightless = tube.clone();
957        let PartKind::BodyTube(sizes) = &mut weightless.kind else {
958            unreachable!("found as a body tube")
959        };
960        sizes.material.density = Some(0.0);
961        assert!(matches!(
962            Tube::from_catalog(&weightless),
963            Err(Error::Catalog {
964                problem: CatalogProblem::NoVolume,
965                ..
966            })
967        ));
968        for bad in [-1.0, f64::NAN, f64::INFINITY] {
969            let mut part = tube.clone();
970            part.mass_kg = Some(bad);
971            let Err(Error::Domain { what, .. }) = Tube::from_catalog(&part) else {
972                panic!("a stated mass of {bad} kg is refused")
973            };
974            assert_eq!(what, "catalog part's stated mass, kg");
975        }
976    }
977
978    #[test]
979    fn a_clipped_transition_weighs_as_its_profile() {
980        let kraft = material("kraft_phenolic").unwrap();
981        let weigh = |clipped: bool| {
982            let mut rocket = Rocket::new("clipped", 0.1).unwrap();
983            rocket
984                .add_tube(Tube::new(0.1, 0.001, kraft.clone()))
985                .unwrap()
986                .add_transition(
987                    Transition::conical(0.1, 0.05, 0.002, kraft.clone())
988                        .with_shape(NoseShape::Elliptical {})
989                        .with_clipped(clipped),
990                )
991                .unwrap();
992            let layout = rocket.design().layout().unwrap();
993            let placed = &layout.components[layout.components.len() - 1];
994            let Part::Transition(built) = &placed.part else {
995                panic!("the last part is the transition")
996            };
997            assert_eq!(built.clipped, clipped);
998            placed.own.mass_kg
999        };
1000        let (clipped, stretched) = (weigh(true), weigh(false));
1001        assert!((clipped - stretched).abs() > 1e-3 * stretched);
1002    }
1003
1004    #[test]
1005    fn weightless_lines_are_not_scaled_or_renamed() {
1006        let chute = bundled()
1007            .parts
1008            .iter()
1009            .find(|part| {
1010                matches!(&part.kind, PartKind::Parachute(chute) if chute.line_material.is_none())
1011                    && part.mass_kg.is_some()
1012            })
1013            .expect("a parachute naming no line material that states its mass");
1014        let fitting = Fitting::from_catalog(chute).unwrap();
1015        let Part::Parachute(built) = &fitting.part else {
1016            panic!("a parachute")
1017        };
1018        assert_eq!(built.line_material.name, "none defined");
1019        assert!(
1020            built
1021                .canopy_material
1022                .name
1023                .ends_with(", density set by the part's stated mass")
1024        );
1025        let weighed = fitting.part.mass_properties(Some(1.0)).unwrap().mass_kg;
1026        let stated = chute.mass_kg.unwrap();
1027        assert!((weighed - stated).abs() <= 1e-14 * stated);
1028    }
1029}