1use 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#[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 #[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 #[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 #[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 #[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 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 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 _ => {}
215 }
216 }
217 Ok(fitting)
218 }
219
220 #[must_use]
222 pub fn at(mut self, position: Position) -> Self {
223 self.position = position;
224 self
225 }
226
227 #[must_use]
229 pub fn named(mut self, name: &str) -> Self {
230 name.clone_into(&mut self.name);
231 self
232 }
233
234 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 pub(super) fn into_parts(self) -> (Part, Position, String) {
250 (self.part, self.position, self.name)
251 }
252}
253
254const 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 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 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 #[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 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 #[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 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
439fn 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 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
462fn scale(material: &mut Material, factor: f64) {
464 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
489fn 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
505fn label(part: &orc::Part) -> String {
507 format!("{} {}", part.manufacturer, part.part_number)
508}
509
510fn 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
535fn 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
545fn 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
555fn 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
575fn 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
588fn 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 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 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 assert!((mass_kg(&cut, "motor-tube") - 0.25 * stated_kg).abs() <= 1e-14 * stated_kg);
652
653 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 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 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 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 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 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 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 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 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 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 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 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 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}