1use std::f64::consts::PI;
25
26use hpr_core::DVec3;
27use serde::{Deserialize, Serialize};
28
29use crate::error::DesignError;
30use crate::mass::{MassProperties, Placement};
31use crate::material::Material;
32use crate::shapes::{NoseShape, Profile, check_dimension};
33use crate::solids::{Wall, revolve};
34
35pub(crate) fn hollow_cylinder(
44 part: &'static str,
45 density_kg_m3: f64,
46 length_m: f64,
47 outer_radius_m: f64,
48 thickness_m: f64,
49) -> Result<MassProperties, DesignError> {
50 check_dimension("length", length_m, false)?;
51 check_dimension("outer radius", outer_radius_m, false)?;
52 check_dimension("wall thickness", thickness_m, true)?;
53 if thickness_m > outer_radius_m {
54 return Err(DesignError::Geometry(format!(
55 "{part}: wall thickness {thickness_m} m exceeds the outer radius {outer_radius_m} m"
56 )));
57 }
58 let (big, small) = (outer_radius_m, outer_radius_m - thickness_m);
59 let mass = density_kg_m3 * PI * (big * big - small * small) * length_m;
60 let radii = big * big + small * small;
61 Ok(MassProperties::axisymmetric(
62 mass,
63 DVec3::new(0.0, 0.0, -0.5 * length_m),
64 0.5 * mass * radii,
65 mass * (0.25 * radii + length_m * length_m / 12.0),
66 ))
67}
68
69fn solid_cylinder(mass_kg: f64, length_m: f64, radius_m: f64) -> MassProperties {
71 MassProperties::axisymmetric(
72 mass_kg,
73 DVec3::new(0.0, 0.0, -0.5 * length_m),
74 0.5 * mass_kg * radius_m * radius_m,
75 mass_kg * (3.0 * radius_m * radius_m + length_m * length_m) / 12.0,
76 )
77}
78
79fn check_angle(what: &'static str, value: f64) -> Result<(), DesignError> {
81 if value.is_finite() {
82 Ok(())
83 } else {
84 Err(DesignError::Domain { what, value })
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
90#[serde(deny_unknown_fields)]
91pub struct Shoulder {
92 pub length_m: f64,
94 pub outer_radius_m: f64,
96 pub thickness_m: f64,
98 #[serde(default)]
100 pub capped: bool,
101}
102
103impl Shoulder {
104 fn mass_properties(
107 &self,
108 density_kg_m3: f64,
109 near_z_m: f64,
110 forward: bool,
111 ) -> Result<MassProperties, DesignError> {
112 let tube = hollow_cylinder(
113 "shoulder",
114 density_kg_m3,
115 self.length_m,
116 self.outer_radius_m,
117 self.thickness_m,
118 )?;
119 let fore_z = if forward {
121 near_z_m + self.length_m
122 } else {
123 near_z_m
124 };
125 let mut parts = vec![tube.translated(DVec3::new(0.0, 0.0, fore_z))];
126 let inner = self.outer_radius_m - self.thickness_m;
127 if self.capped && inner > 0.0 && self.thickness_m <= self.length_m {
128 let cap = hollow_cylinder(
129 "shoulder cap",
130 density_kg_m3,
131 self.thickness_m,
132 inner,
133 inner,
134 )?;
135 let cap_fore = if forward {
136 fore_z
137 } else {
138 fore_z - self.length_m + self.thickness_m
139 };
140 parts.push(cap.translated(DVec3::new(0.0, 0.0, cap_fore)));
141 } else if self.capped {
142 return Err(DesignError::Geometry(
143 "a capped shoulder needs a hollow tube at least as long as its wall is thick"
144 .to_owned(),
145 ));
146 }
147 Ok(MassProperties::combine(&parts))
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
153#[serde(deny_unknown_fields)]
154pub struct NoseCone {
155 pub shape: NoseShape,
157 pub length_m: f64,
159 pub base_radius_m: f64,
161 pub wall: Wall,
163 #[serde(default)]
165 pub shoulder: Option<Shoulder>,
166 pub material: Material,
168}
169
170impl NoseCone {
171 pub fn profile(&self) -> Result<Profile, DesignError> {
177 Profile::nose(self.shape, self.length_m, self.base_radius_m)
178 }
179
180 pub fn mass_properties(&self) -> Result<MassProperties, DesignError> {
186 let density = self.material.bulk_kg_m3("nose cone")?;
187 let body = revolved(&self.profile()?, self.wall, density)?;
188 match &self.shoulder {
189 None => Ok(body),
190 Some(shoulder) => {
191 let aft = shoulder.mass_properties(density, -self.length_m, false)?;
192 Ok(MassProperties::combine([&body, &aft]))
193 }
194 }
195 }
196}
197
198fn revolved(profile: &Profile, wall: Wall, density: f64) -> Result<MassProperties, DesignError> {
200 let g = revolve(profile, wall)?;
201 Ok(MassProperties::axisymmetric(
202 density * g.volume_m3,
203 DVec3::new(0.0, 0.0, -g.centroid_m),
204 density * g.axial_m5,
205 density * g.transverse_m5,
206 ))
207}
208
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
211#[serde(deny_unknown_fields)]
212pub struct Transition {
213 pub shape: NoseShape,
215 #[serde(default)]
217 pub clipped: bool,
218 pub length_m: f64,
220 pub fore_radius_m: f64,
222 pub aft_radius_m: f64,
224 pub wall: Wall,
226 #[serde(default)]
228 pub fore_shoulder: Option<Shoulder>,
229 #[serde(default)]
231 pub aft_shoulder: Option<Shoulder>,
232 pub material: Material,
234}
235
236impl Transition {
237 pub fn profile(&self) -> Result<Profile, DesignError> {
243 Profile::transition(
244 self.shape,
245 self.length_m,
246 self.fore_radius_m,
247 self.aft_radius_m,
248 self.clipped,
249 )
250 }
251
252 pub fn mass_properties(&self) -> Result<MassProperties, DesignError> {
259 let density = self.material.bulk_kg_m3("transition")?;
260 let mut parts = vec![revolved(&self.profile()?, self.wall, density)?];
261 if let Some(shoulder) = &self.fore_shoulder {
262 parts.push(shoulder.mass_properties(density, 0.0, true)?);
263 }
264 if let Some(shoulder) = &self.aft_shoulder {
265 parts.push(shoulder.mass_properties(density, -self.length_m, false)?);
266 }
267 Ok(MassProperties::combine(&parts))
268 }
269}
270
271#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
273#[serde(deny_unknown_fields)]
274pub struct BodyTube {
275 pub length_m: f64,
277 pub outer_radius_m: f64,
279 pub thickness_m: f64,
281 pub material: Material,
283}
284
285impl BodyTube {
286 pub fn mass_properties(&self) -> Result<MassProperties, DesignError> {
296 let density = self.material.bulk_kg_m3("body tube")?;
297 if self.length_m == 0.0 {
298 check_dimension("outer radius", self.outer_radius_m, true)?;
299 check_dimension("wall thickness", self.thickness_m, true)?;
300 if self.thickness_m > self.outer_radius_m {
301 return Err(DesignError::Geometry(format!(
302 "body tube: wall thickness {} m exceeds the outer radius {} m",
303 self.thickness_m, self.outer_radius_m
304 )));
305 }
306 return Ok(MassProperties::ZERO);
307 }
308 hollow_cylinder(
309 "body tube",
310 density,
311 self.length_m,
312 self.outer_radius_m,
313 self.thickness_m,
314 )
315 }
316}
317
318#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
330#[serde(deny_unknown_fields)]
331pub struct InnerTube {
332 pub length_m: f64,
334 pub outer_radius_m: f64,
336 pub thickness_m: f64,
338 #[serde(default)]
340 pub radial_offset_m: f64,
341 #[serde(default)]
343 pub angle_rad: f64,
344 pub material: Material,
346 #[serde(default, skip_serializing_if = "Vec::is_empty")]
349 pub cluster_m: Vec<[f64; 2]>,
350}
351
352impl InnerTube {
353 pub fn mass_properties(&self) -> Result<MassProperties, DesignError> {
360 check_dimension("inner tube radial offset", self.radial_offset_m, true)?;
361 check_angle("inner tube angle", self.angle_rad)?;
362 let density = self.material.bulk_kg_m3("inner tube")?;
363 let tube = hollow_cylinder(
364 "inner tube",
365 density,
366 self.length_m,
367 self.outer_radius_m,
368 self.thickness_m,
369 )?
370 .translated(DVec3::new(self.radial_offset_m, 0.0, 0.0))
371 .rolled(self.angle_rad);
372 Ok(MassProperties::copied(tube, &self.tubes_m()?))
373 }
374
375 pub fn tubes_m(&self) -> Result<Vec<[f64; 2]>, DesignError> {
382 if self.cluster_m.is_empty() {
383 return Ok(vec![[0.0, 0.0]]);
384 }
385 for &[x, y] in &self.cluster_m {
386 for value in [x, y] {
387 if !value.is_finite() {
388 return Err(DesignError::Domain {
389 what: "inner tube cluster offset (m)",
390 value,
391 });
392 }
393 }
394 }
395 Ok(self.cluster_m.clone())
396 }
397}
398
399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
428#[serde(deny_unknown_fields)]
429pub struct PodSet {
430 pub count: u32,
432 pub radial_offset_m: f64,
434 #[serde(default)]
436 pub angle_rad: f64,
437}
438
439impl PodSet {
440 pub const MAX_COUNT: u32 = 64;
443
444 pub fn pods(&self) -> Result<Vec<Placement>, DesignError> {
453 if self.count == 0 || self.count > Self::MAX_COUNT {
454 return Err(DesignError::Domain {
455 what: "pod count (1 to 64)",
456 value: f64::from(self.count),
457 });
458 }
459 check_dimension("pod radial offset", self.radial_offset_m, true)?;
460 check_angle("pod angle", self.angle_rad)?;
461 let step = std::f64::consts::TAU / f64::from(self.count);
462 Ok((0..self.count)
463 .map(|k| {
464 let phi = self.angle_rad + step * f64::from(k);
465 Placement {
466 offset_m: [
467 self.radial_offset_m * phi.cos(),
468 self.radial_offset_m * phi.sin(),
469 ],
470 roll_rad: phi,
471 }
472 })
473 .collect())
474 }
475}
476
477#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
479#[serde(deny_unknown_fields)]
480pub struct CenteringRing {
481 pub length_m: f64,
483 pub outer_radius_m: f64,
485 pub inner_radius_m: f64,
487 pub material: Material,
489}
490
491impl CenteringRing {
492 pub fn bulkhead(length_m: f64, radius_m: f64, material: Material) -> Self {
494 Self {
495 length_m,
496 outer_radius_m: radius_m,
497 inner_radius_m: 0.0,
498 material,
499 }
500 }
501
502 pub fn mass_properties(&self) -> Result<MassProperties, DesignError> {
508 check_dimension("ring inner radius", self.inner_radius_m, true)?;
509 if self.inner_radius_m >= self.outer_radius_m {
513 return Err(DesignError::Geometry(format!(
514 "a centering ring's bore ({} m) reaches its outer radius ({} m), leaving no ring",
515 self.inner_radius_m, self.outer_radius_m
516 )));
517 }
518 let density = self.material.bulk_kg_m3("centering ring")?;
519 hollow_cylinder(
520 "centering ring",
521 density,
522 self.length_m,
523 self.outer_radius_m,
524 self.outer_radius_m - self.inner_radius_m,
525 )
526 }
527}
528
529pub const MAX_INSTANCES: u32 = 64;
532
533fn instances(
541 one: MassProperties,
542 count: u32,
543 spacing_m: f64,
544) -> Result<MassProperties, DesignError> {
545 if count == 0 || count > MAX_INSTANCES {
546 return Err(DesignError::Domain {
547 what: "instance count (1 to 64)",
548 value: f64::from(count),
549 });
550 }
551 check_dimension("instance spacing", spacing_m, true)?;
552 let copies: Vec<MassProperties> = (0..count)
553 .map(|k| one.translated(DVec3::new(0.0, 0.0, -spacing_m * f64::from(k))))
554 .collect();
555 Ok(MassProperties::combine(&copies))
556}
557
558#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
560#[serde(deny_unknown_fields)]
561pub struct LaunchLug {
562 pub length_m: f64,
564 pub outer_radius_m: f64,
566 pub thickness_m: f64,
568 #[serde(default)]
570 pub angle_rad: f64,
571 #[serde(default = "one")]
573 pub count: u32,
574 #[serde(default)]
576 pub spacing_m: f64,
577 pub material: Material,
579}
580
581fn one() -> u32 {
582 1
583}
584
585impl LaunchLug {
586 pub fn mass_properties(&self, body_radius_m: f64) -> Result<MassProperties, DesignError> {
592 check_dimension("body radius", body_radius_m, true)?;
593 check_angle("launch lug angle", self.angle_rad)?;
594 let density = self.material.bulk_kg_m3("launch lug")?;
595 let tube = hollow_cylinder(
596 "launch lug",
597 density,
598 self.length_m,
599 self.outer_radius_m,
600 self.thickness_m,
601 )?;
602 let placed = tube
603 .translated(DVec3::new(body_radius_m + self.outer_radius_m, 0.0, 0.0))
604 .rolled(self.angle_rad);
605 instances(placed, self.count, self.spacing_m)
606 }
607}
608
609#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
612#[serde(deny_unknown_fields)]
613pub struct RailButton {
614 pub outer_diameter_m: f64,
616 pub inner_diameter_m: f64,
618 pub height_m: f64,
620 pub base_height_m: f64,
622 pub flange_height_m: f64,
624 #[serde(default)]
630 pub screw_height_m: f64,
631 #[serde(default)]
633 pub angle_rad: f64,
634 #[serde(default = "one")]
636 pub count: u32,
637 #[serde(default)]
639 pub spacing_m: f64,
640 pub material: Material,
642}
643
644impl RailButton {
645 pub fn mass_properties(&self, body_radius_m: f64) -> Result<MassProperties, DesignError> {
662 check_dimension("body radius", body_radius_m, true)?;
663 check_dimension("rail button outer diameter", self.outer_diameter_m, false)?;
664 check_dimension("rail button inner diameter", self.inner_diameter_m, false)?;
665 check_dimension("rail button height", self.height_m, false)?;
666 check_dimension("rail button base height", self.base_height_m, true)?;
667 check_dimension("rail button flange height", self.flange_height_m, true)?;
668 check_dimension("rail button screw height", self.screw_height_m, true)?;
669 check_angle("rail button angle", self.angle_rad)?;
670 let waist = self.height_m - self.base_height_m - self.flange_height_m;
671 if waist < 0.0 || self.inner_diameter_m > self.outer_diameter_m {
672 return Err(DesignError::Geometry(
673 "a rail button's base and flange must fit in its height, and its waist in its flange"
674 .to_owned(),
675 ));
676 }
677 let density = self.material.bulk_kg_m3("rail button")?;
678 let center_z = -0.5 * self.outer_diameter_m;
679 let disc = |diameter: f64, height: f64, from: f64| -> MassProperties {
680 let a = 0.5 * diameter;
681 let mass = density * PI * a * a * height;
682 let across = mass * (3.0 * a * a + height * height) / 12.0;
683 MassProperties {
684 mass_kg: mass,
685 cg_m: DVec3::new(body_radius_m + from + 0.5 * height, 0.0, center_z),
686 inertia_kg_m2: hpr_core::DMat3::from_diagonal(DVec3::new(
687 0.5 * mass * a * a,
688 across,
689 across,
690 )),
691 }
692 };
693 let mut stack = vec![
694 disc(self.outer_diameter_m, self.base_height_m, 0.0),
695 disc(self.inner_diameter_m, waist, self.base_height_m),
696 disc(
697 self.outer_diameter_m,
698 self.flange_height_m,
699 self.base_height_m + waist,
700 ),
701 ];
702 if self.screw_height_m > 0.0 {
704 let (a, h) = (0.5 * self.outer_diameter_m, self.screw_height_m);
705 let mass = density * 2.0 / 3.0 * PI * a * a * h;
706 let across = mass * (a * a / 5.0 + 19.0 * h * h / 320.0);
707 stack.push(MassProperties {
708 mass_kg: mass,
709 cg_m: DVec3::new(body_radius_m + self.height_m + 0.375 * h, 0.0, center_z),
710 inertia_kg_m2: hpr_core::DMat3::from_diagonal(DVec3::new(
711 0.4 * mass * a * a,
712 across,
713 across,
714 )),
715 });
716 }
717 let one = MassProperties::combine(&stack).rolled(self.angle_rad);
718 instances(one, self.count, self.spacing_m)
719 }
720}
721
722#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
724#[serde(deny_unknown_fields)]
725pub struct Packing {
726 pub length_m: f64,
728 pub radius_m: f64,
730 #[serde(default)]
732 pub radial_offset_m: f64,
733 #[serde(default)]
735 pub angle_rad: f64,
736}
737
738impl Packing {
739 pub(crate) fn place(&self, mass_kg: f64) -> Result<MassProperties, DesignError> {
741 check_dimension("mass", mass_kg, true)?;
742 check_dimension("packed length", self.length_m, true)?;
743 check_dimension("packed radius", self.radius_m, true)?;
744 check_dimension("radial offset", self.radial_offset_m, true)?;
745 check_angle("packing angle", self.angle_rad)?;
746 Ok(solid_cylinder(mass_kg, self.length_m, self.radius_m)
747 .translated(DVec3::new(self.radial_offset_m, 0.0, 0.0))
748 .rolled(self.angle_rad))
749 }
750}
751
752#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
754#[serde(deny_unknown_fields)]
755pub struct MassComponent {
756 pub mass_kg: f64,
758 pub packing: Packing,
760}
761
762impl MassComponent {
763 pub fn mass_properties(&self) -> Result<MassProperties, DesignError> {
769 self.packing.place(self.mass_kg)
770 }
771}
772
773#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
775#[serde(deny_unknown_fields)]
776pub struct Parachute {
777 pub diameter_m: f64,
779 pub canopy_material: Material,
781 pub line_count: u32,
783 pub line_length_m: f64,
785 pub line_material: Material,
787 pub packing: Packing,
789}
790
791impl Parachute {
792 pub fn mass_kg(&self) -> Result<f64, DesignError> {
798 check_dimension("parachute diameter", self.diameter_m, true)?;
799 check_dimension("shroud line length", self.line_length_m, true)?;
800 let canopy = self.canopy_material.surface_kg_m2("parachute canopy")?;
801 let line = self.line_material.line_kg_m("shroud line")?;
802 Ok(canopy * PI * self.diameter_m * self.diameter_m / 4.0
803 + f64::from(self.line_count) * self.line_length_m * line)
804 }
805
806 pub fn mass_properties(&self) -> Result<MassProperties, DesignError> {
812 self.packing.place(self.mass_kg()?)
813 }
814}
815
816#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
818#[serde(deny_unknown_fields)]
819pub struct Streamer {
820 pub length_m: f64,
822 pub width_m: f64,
824 pub material: Material,
826 pub packing: Packing,
828}
829
830impl Streamer {
831 pub fn mass_properties(&self) -> Result<MassProperties, DesignError> {
837 check_dimension("streamer length", self.length_m, true)?;
838 check_dimension("streamer width", self.width_m, true)?;
839 let density = self.material.surface_kg_m2("streamer")?;
840 self.packing.place(density * self.length_m * self.width_m)
841 }
842}
843
844#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
846#[serde(deny_unknown_fields)]
847pub struct ShockCord {
848 pub length_m: f64,
850 pub material: Material,
852 pub packing: Packing,
854}
855
856impl ShockCord {
857 pub fn mass_properties(&self) -> Result<MassProperties, DesignError> {
863 check_dimension("shock cord length", self.length_m, true)?;
864 let density = self.material.line_kg_m("shock cord")?;
865 self.packing.place(density * self.length_m)
866 }
867}
868
869#[cfg(test)]
870mod tests {
871 use super::*;
872 use hpr_core::DMat3;
873
874 fn close(got: f64, want: f64, rel: f64, what: &str) {
875 let err = if want == 0.0 {
876 got.abs()
877 } else {
878 ((got - want) / want).abs()
879 };
880 assert!(err <= rel, "{what}: {got} vs {want} (relative {err:e})");
881 }
882
883 fn cardboard() -> Material {
884 Material::bulk("cardboard", 790.0)
885 }
886
887 #[test]
888 fn a_nose_cone_with_a_capped_shoulder_adds_up_by_hand() {
889 let (l, r, rho) = (0.2, 0.03, 1240.0);
891 let (ls, rs, ts) = (0.05, 0.028, 0.002);
892 let nose = NoseCone {
893 shape: NoseShape::Conical {},
894 length_m: l,
895 base_radius_m: r,
896 wall: Wall::Filled {},
897 shoulder: Some(Shoulder {
898 length_m: ls,
899 outer_radius_m: rs,
900 thickness_m: ts,
901 capped: true,
902 }),
903 material: Material::bulk("PLA", rho),
904 };
905 let g = nose.mass_properties().unwrap();
906 let cone_m = rho * PI * r * r * l / 3.0;
907 let tube_m = rho * PI * (rs * rs - (rs - ts).powi(2)) * ls;
908 let cap_m = rho * PI * (rs - ts).powi(2) * ts;
909 close(g.mass_kg, cone_m + tube_m + cap_m, 1e-12, "mass");
910 let moment = cone_m * (-0.75 * l) + tube_m * (-l - ls / 2.0) + cap_m * (-l - ls + ts / 2.0);
911 close(
912 g.cg_m.z,
913 moment / (cone_m + tube_m + cap_m),
914 1e-12,
915 "center",
916 );
917 let ri = rs - ts;
919 let axial =
920 0.3 * cone_m * r * r + 0.5 * tube_m * (rs * rs + ri * ri) + 0.5 * cap_m * ri * ri;
921 close(g.inertia_kg_m2.z_axis.z, axial, 1e-11, "axial");
922 let zc = g.cg_m.z;
924 let transverse = 3.0 / 80.0 * cone_m * (4.0 * r * r + l * l)
925 + cone_m * (-0.75 * l - zc).powi(2)
926 + tube_m * ((rs * rs + ri * ri) / 4.0 + ls * ls / 12.0)
927 + tube_m * (-l - ls / 2.0 - zc).powi(2)
928 + cap_m * (3.0 * ri * ri + ts * ts) / 12.0
929 + cap_m * (-l - ls + ts / 2.0 - zc).powi(2);
930 close(g.inertia_kg_m2.x_axis.x, transverse, 1e-11, "transverse");
931 }
932
933 #[test]
934 fn shoulders_sit_beyond_the_right_ends() {
935 let shoulder = Shoulder {
936 length_m: 0.04,
937 outer_radius_m: 0.02,
938 thickness_m: 0.001,
939 capped: false,
940 };
941 let t = Transition {
942 shape: NoseShape::Conical {},
943 clipped: false,
944 length_m: 0.1,
945 fore_radius_m: 0.021,
946 aft_radius_m: 0.03,
947 wall: Wall::Shell { thickness_m: 0.002 },
948 fore_shoulder: Some(shoulder),
949 aft_shoulder: None,
950 material: cardboard(),
951 };
952 let fore = shoulder.mass_properties(790.0, 0.0, true).unwrap();
953 close(fore.cg_m.z, 0.02, 1e-15, "fore shoulder center");
954 let aft = shoulder.mass_properties(790.0, -0.1, false).unwrap();
955 close(aft.cg_m.z, -0.12, 1e-15, "aft shoulder center");
956 let whole = t.mass_properties().unwrap();
957 let body = Transition {
958 fore_shoulder: None,
959 ..t.clone()
960 }
961 .mass_properties()
962 .unwrap();
963 close(whole.mass_kg, body.mass_kg + fore.mass_kg, 1e-14, "mass");
964 }
965
966 #[test]
972 fn a_cluster_is_its_tubes_each_with_its_parallel_axis_term() {
973 let tube = |cluster_m: Vec<[f64; 2]>| InnerTube {
974 length_m: 0.3,
975 outer_radius_m: 0.015,
976 thickness_m: 0.001,
977 radial_offset_m: 0.01,
978 angle_rad: 0.0,
979 material: cardboard(),
980 cluster_m,
981 };
982 let one = hollow_cylinder("t", 790.0, 0.3, 0.015, 0.001).unwrap();
983 let single = tube(Vec::new()).mass_properties().unwrap();
984 assert_eq!(
985 single,
986 one.translated(DVec3::new(0.01, 0.0, 0.0)).rolled(0.0)
987 );
988 assert_eq!(tube(Vec::new()).tubes_m().unwrap(), [[0.0, 0.0]]);
989
990 let square = vec![[0.02, 0.0], [0.0, 0.02], [-0.02, 0.0], [0.0, -0.02]];
991 let g = tube(square.clone()).mass_properties().unwrap();
992 close(g.mass_kg, 4.0 * one.mass_kg, 1e-15, "four tubes");
993 assert!((g.cg_m - DVec3::new(0.01, 0.0, -0.15)).length() < 1e-15);
994 let roll: f64 = square
995 .iter()
996 .map(|[x, y]| {
997 let (dx, dy) = (0.01 + x, *y);
998 one.inertia_kg_m2.z_axis.z + one.mass_kg * (dx * dx + dy * dy)
999 })
1000 .sum();
1001 close(
1002 g.inertia_about(DVec3::new(0.0, 0.0, -0.15)).z_axis.z,
1003 roll,
1004 1e-15,
1005 "roll about the body axis",
1006 );
1007
1008 let Err(DesignError::Domain { what, value }) =
1009 tube(vec![[f64::NAN, 0.0]]).mass_properties()
1010 else {
1011 panic!("refused");
1012 };
1013 assert_eq!(what, "inner tube cluster offset (m)");
1014 assert!(value.is_nan());
1015 }
1016
1017 #[test]
1018 fn off_axis_parts_carry_their_offsets() {
1019 let tube = InnerTube {
1022 length_m: 0.3,
1023 outer_radius_m: 0.015,
1024 thickness_m: 0.001,
1025 radial_offset_m: 0.03,
1026 angle_rad: PI / 2.0,
1027 material: cardboard(),
1028 cluster_m: Vec::new(),
1029 };
1030 let g = tube.mass_properties().unwrap();
1031 assert!((g.cg_m - DVec3::new(0.0, 0.03, -0.15)).length() < 1e-15);
1032 let own = hollow_cylinder("t", 790.0, 0.3, 0.015, 0.001).unwrap();
1033 close(
1035 g.inertia_about(DVec3::new(0.0, 0.0, -0.15)).z_axis.z,
1036 own.inertia_kg_m2.z_axis.z + own.mass_kg * 0.03 * 0.03,
1037 1e-13,
1038 "axial about the body axis",
1039 );
1040
1041 let lug = LaunchLug {
1042 length_m: 0.05,
1043 outer_radius_m: 0.003,
1044 thickness_m: 0.0005,
1045 angle_rad: 0.0,
1046 count: 2,
1047 spacing_m: 0.4,
1048 material: Material::bulk("brass", 8500.0),
1049 };
1050 let g = lug.mass_properties(0.02).unwrap();
1051 let one = hollow_cylinder("l", 8500.0, 0.05, 0.003, 0.0005).unwrap();
1052 close(g.mass_kg, 2.0 * one.mass_kg, 1e-14, "two lugs");
1053 assert!((g.cg_m - DVec3::new(0.023, 0.0, -0.225)).length() < 1e-15);
1054 close(
1055 g.inertia_kg_m2.x_axis.x,
1056 one.inertia_kg_m2.x_axis.x * 2.0 + 2.0 * one.mass_kg * 0.2 * 0.2,
1057 1e-13,
1058 "two lugs transverse",
1059 );
1060
1061 let mass = MassComponent {
1062 mass_kg: 0.25,
1063 packing: Packing {
1064 length_m: 0.1,
1065 radius_m: 0.02,
1066 radial_offset_m: 0.01,
1067 angle_rad: PI,
1068 },
1069 };
1070 let g = mass.mass_properties().unwrap();
1071 assert!((g.cg_m - DVec3::new(-0.01, 0.0, -0.05)).length() < 1e-15);
1072 let expected = DMat3::from_diagonal(DVec3::new(
1073 0.25 * (3.0 * 0.0004 + 0.01) / 12.0,
1074 0.25 * (3.0 * 0.0004 + 0.01) / 12.0,
1075 0.25 * 0.0004 / 2.0,
1076 ));
1077 let diff = (g.inertia_kg_m2 - expected)
1078 .to_cols_array()
1079 .iter()
1080 .fold(0.0f64, |m, v| m.max(v.abs()));
1081 assert!(diff < 1e-18, "{g:?}");
1082 }
1083
1084 #[test]
1085 fn a_lug_or_button_count_over_the_bound_is_refused_and_the_bound_is_weighed() {
1086 let lug = |count| LaunchLug {
1087 length_m: 0.05,
1088 outer_radius_m: 0.003,
1089 thickness_m: 0.0005,
1090 angle_rad: 0.0,
1091 count,
1092 spacing_m: 0.01,
1093 material: Material::bulk("brass", 8500.0),
1094 };
1095 let button = |count| RailButton {
1096 outer_diameter_m: 0.0111,
1097 inner_diameter_m: 0.0063,
1098 height_m: 0.0086,
1099 base_height_m: 0.0025,
1100 flange_height_m: 0.0022,
1101 screw_height_m: 0.0,
1102 angle_rad: 0.0,
1103 count,
1104 spacing_m: 0.01,
1105 material: Material::bulk("acetal", 1420.0),
1106 };
1107 for count in [0, MAX_INSTANCES + 1, 4_000_000_000] {
1109 for result in [
1110 lug(count).mass_properties(0.02),
1111 button(count).mass_properties(0.02),
1112 ] {
1113 match result {
1114 Err(DesignError::Domain { what, value }) => {
1115 assert_eq!(what, "instance count (1 to 64)");
1116 assert_eq!(value, f64::from(count));
1117 }
1118 other => panic!("count {count}: {other:?}"),
1119 }
1120 }
1121 }
1122 let one = lug(1).mass_properties(0.02).unwrap().mass_kg;
1123 let all = lug(MAX_INSTANCES).mass_properties(0.02).unwrap().mass_kg;
1124 close(all, 64.0 * one, 1e-13, "64 lugs");
1125 let one = button(1).mass_properties(0.02).unwrap().mass_kg;
1126 let all = button(MAX_INSTANCES).mass_properties(0.02).unwrap().mass_kg;
1127 close(all, 64.0 * one, 1e-13, "64 buttons");
1128 }
1129
1130 #[test]
1138 fn a_rail_buttons_screw_head_is_half_an_ellipsoid() {
1139 let button = |screw_height_m: f64| RailButton {
1140 outer_diameter_m: 0.010,
1141 inner_diameter_m: 0.006,
1142 height_m: 0.008,
1143 base_height_m: 0.002,
1144 flange_height_m: 0.002,
1145 screw_height_m,
1146 angle_rad: 0.0,
1147 count: 1,
1148 spacing_m: 0.0,
1149 material: Material::bulk("probe", 1000.0),
1150 };
1151 let openrocket_m3 = [
1152 (0.0, 4.272566008882119e-07),
1153 (0.001, 4.796164800084878e-07),
1154 (0.002, 5.319763591287636e-07),
1155 (0.005, 6.890559964895911e-07),
1156 ];
1157 let openrocket_two_thirds = f64::from(2.0_f32 / 3.0_f32);
1159 for (screw_m, volume_m3) in openrocket_m3 {
1160 let mass = button(screw_m).mass_properties(0.025).unwrap();
1161 let head_gap_m3 = (2.0 / 3.0 - openrocket_two_thirds)
1162 * std::f64::consts::PI
1163 * 0.005
1164 * 0.005
1165 * screw_m;
1166 close(
1167 mass.mass_kg / 1000.0,
1168 volume_m3 + head_gap_m3,
1169 1e-14,
1170 "volume",
1171 );
1172 }
1173 assert_eq!(
1175 button(0.0).mass_properties(0.025).unwrap(),
1176 RailButton {
1177 screw_height_m: 0.0,
1178 ..button(0.0)
1179 }
1180 .mass_properties(0.025)
1181 .unwrap()
1182 );
1183 let (h, a) = (0.002, 0.005);
1187 let discs = button(0.0).mass_properties(0.025).unwrap();
1188 let discs_m3 = discs.mass_kg / 1000.0;
1189 let center = |two_thirds: f64, above: f64| {
1190 let head_m3 = two_thirds * std::f64::consts::PI * a * a * h;
1191 (discs_m3 * discs.cg_m.x + head_m3 * (0.033 + above * h)) / (discs_m3 + head_m3)
1192 };
1193 let openrocket = center(openrocket_two_thirds, 4.0 / (3.0 * std::f64::consts::PI));
1194 close(
1195 openrocket,
1196 0.029954493401421,
1197 1e-12,
1198 "OpenRocket's radial center",
1199 );
1200 let screwed = button(h).mass_properties(0.025).unwrap();
1201 close(
1202 screwed.cg_m.x,
1203 center(2.0 / 3.0, 0.375),
1204 1e-12,
1205 "radial center",
1206 );
1207 let head = RailButton {
1210 outer_diameter_m: 2.0 * h,
1211 inner_diameter_m: 1e-12,
1212 height_m: 1e-12,
1213 base_height_m: 0.0,
1214 flange_height_m: 0.0,
1215 ..button(h)
1216 }
1217 .mass_properties(0.025)
1218 .unwrap();
1219 let m = head.mass_kg;
1220 close(
1221 m,
1222 1000.0 * 2.0 / 3.0 * std::f64::consts::PI * h * h * h,
1223 1e-9,
1224 "mass",
1225 );
1226 close(
1227 head.inertia_kg_m2.x_axis.x,
1228 0.4 * m * h * h,
1229 1e-9,
1230 "about its axis",
1231 );
1232 close(
1233 head.inertia_kg_m2.y_axis.y,
1234 83.0 / 320.0 * m * h * h,
1235 1e-9,
1236 "across",
1237 );
1238 close(
1239 head.inertia_kg_m2.z_axis.z,
1240 83.0 / 320.0 * m * h * h,
1241 1e-9,
1242 "across",
1243 );
1244 close(head.cg_m.x, 0.025 + 1e-12 + 0.375 * h, 1e-12, "its center");
1245 assert!(button(-1e-3).mass_properties(0.025).is_err());
1247 }
1248
1249 #[test]
1250 fn recovery_parts_weigh_their_fabric_and_lines() {
1251 let chute = Parachute {
1252 diameter_m: 0.9,
1253 canopy_material: Material::surface("ripstop nylon", 0.0373),
1254 line_count: 8,
1255 line_length_m: 0.9,
1256 line_material: Material::line("nylon line", 0.0016),
1257 packing: Packing {
1258 length_m: 0.1,
1259 radius_m: 0.03,
1260 radial_offset_m: 0.0,
1261 angle_rad: 0.0,
1262 },
1263 };
1264 let canopy = 0.0373 * PI * 0.81 / 4.0;
1265 let lines = 8.0 * 0.9 * 0.0016;
1266 close(chute.mass_kg().unwrap(), canopy + lines, 1e-15, "parachute");
1267 let streamer = Streamer {
1268 length_m: 1.5,
1269 width_m: 0.1,
1270 material: Material::surface("mylar", 0.0353),
1271 packing: chute.packing,
1272 };
1273 close(
1274 streamer.mass_properties().unwrap().mass_kg,
1275 1.5 * 0.1 * 0.0353,
1276 1e-15,
1277 "streamer",
1278 );
1279 let cord = ShockCord {
1280 length_m: 6.0,
1281 material: Material::line("Kevlar", 0.00968),
1282 packing: chute.packing,
1283 };
1284 let g = cord.mass_properties().unwrap();
1285 close(g.mass_kg, 6.0 * 0.00968, 1e-15, "cord");
1286 close(
1287 g.inertia_kg_m2.z_axis.z,
1288 0.5 * g.mass_kg * 0.03 * 0.03,
1289 1e-15,
1290 "cord axial",
1291 );
1292 let wrong = ShockCord {
1293 material: cardboard(),
1294 ..cord
1295 };
1296 assert!(matches!(
1297 wrong.mass_properties(),
1298 Err(DesignError::MaterialKind { .. })
1299 ));
1300 }
1301}
1302
1303#[cfg(test)]
1304mod zero_wall_tests {
1305 use super::*;
1306
1307 #[test]
1312 fn a_tube_of_no_wall_weighs_nothing_and_a_ring_of_no_annulus_is_refused() {
1313 let tube = InnerTube {
1314 length_m: 0.18,
1315 outer_radius_m: 0.025,
1316 thickness_m: 0.0,
1317 radial_offset_m: 0.0,
1318 angle_rad: 0.0,
1319 material: Material::bulk("cardboard", 680.0),
1320 cluster_m: Vec::new(),
1321 };
1322 let mass = tube.mass_properties().expect("a tube of no wall");
1323 assert_eq!(mass.mass_kg, 0.0);
1324 assert_eq!(mass.inertia_kg_m2, hpr_core::DMat3::ZERO);
1325 mass.validate().expect("a valid body");
1326
1327 let walled = InnerTube {
1329 thickness_m: 1e-9,
1330 ..tube.clone()
1331 };
1332 let a_little = walled.mass_properties().expect("a thin wall").mass_kg;
1333 assert!(a_little > 0.0 && a_little < 1e-7, "{a_little}");
1334
1335 let ring = CenteringRing {
1336 length_m: 0.005,
1337 outer_radius_m: 0.0115,
1338 inner_radius_m: 0.0115,
1339 material: Material::bulk("plywood", 630.0),
1340 };
1341 let refused = ring.mass_properties().expect_err("a ring of no annulus");
1342 assert!(refused.to_string().contains("leaving no ring"), "{refused}");
1343 }
1344}