1mod bundled;
52#[cfg(test)]
53mod tests;
54
55pub use bundled::{BUNDLED_FILES, bundled};
56
57use serde::{Deserialize, Serialize};
58
59const QUOTE_LIMIT: usize = 64;
61
62#[derive(Debug, Clone, PartialEq, thiserror::Error)]
64#[non_exhaustive]
65pub enum OrcError {
66 #[error("the elements nest {depth} deep, past the {limit} this reads")]
69 TooDeep {
70 limit: usize,
72 depth: usize,
74 },
75 #[error("not well-formed XML: {reason}")]
77 Xml {
78 reason: String,
80 },
81 #[error("the root element is <{root}>, not <OpenRocketComponent>: not a parts catalog")]
83 NotACatalog {
84 root: String,
86 },
87}
88
89pub const MAX_DEPTH: usize = 16;
93
94pub const MAX_WARNINGS: usize = 1000;
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100#[non_exhaustive]
101pub enum WarningKind {
102 PartLeftOut,
106 MaterialLeftOut,
109 MaterialUndefined,
111 MaterialRepeated,
114 Ignored,
117 Repeated,
119 Implausible,
122 TooMany,
124}
125
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128#[non_exhaustive]
129pub struct Warning {
130 pub at: String,
132 pub kind: WarningKind,
134 pub message: String,
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140#[non_exhaustive]
141pub struct CatalogFile {
142 pub catalog: Catalog,
144 pub version: Option<String>,
146 pub materials: Vec<CatalogMaterial>,
148 pub warnings: Vec<Warning>,
151}
152
153#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
155pub struct Catalog {
156 pub parts: Vec<Part>,
158}
159
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[non_exhaustive]
163pub struct Part {
164 pub file: String,
166 pub manufacturer: String,
168 pub part_number: String,
170 pub description: String,
173 pub mass_kg: Option<f64>,
176 pub kind: PartKind,
178}
179
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185#[serde(tag = "kind", rename_all = "snake_case")]
186#[non_exhaustive]
187pub enum PartKind {
188 BodyTube(Tube),
190 TubeCoupler(Tube),
193 EngineBlock(Tube),
195 CenteringRing(Tube),
197 LaunchLug(Tube),
199 Bulkhead(Bulkhead),
201 NoseCone(NoseCone),
203 Transition(Transition),
205 Parachute(Parachute),
207 Streamer(Streamer),
209}
210
211#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213#[non_exhaustive]
214pub struct Tube {
215 pub inner_diameter_m: f64,
217 pub outer_diameter_m: f64,
219 pub length_m: f64,
221 pub material: MaterialRef,
223}
224
225impl Tube {
226 #[must_use]
228 pub fn thickness_m(&self) -> f64 {
229 (self.outer_diameter_m - self.inner_diameter_m) / 2.0
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
235#[non_exhaustive]
236pub struct Bulkhead {
237 pub outer_diameter_m: f64,
239 pub length_m: f64,
241 pub filled: Option<bool>,
243 pub material: MaterialRef,
245}
246
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
249#[non_exhaustive]
250pub struct NoseCone {
251 pub shape: Shape,
253 pub length_m: f64,
255 pub outer_diameter_m: f64,
257 pub shoulder_diameter_m: f64,
259 pub shoulder_length_m: f64,
261 pub filled: Option<bool>,
263 pub thickness_m: Option<f64>,
265 pub material: MaterialRef,
267}
268
269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
271#[non_exhaustive]
272pub struct Transition {
273 pub shape: Shape,
275 pub length_m: f64,
277 pub fore_outer_diameter_m: f64,
279 pub fore_shoulder_diameter_m: f64,
281 pub fore_shoulder_length_m: f64,
283 pub aft_outer_diameter_m: f64,
285 pub aft_shoulder_diameter_m: f64,
287 pub aft_shoulder_length_m: f64,
289 pub filled: Option<bool>,
291 pub thickness_m: Option<f64>,
293 pub material: MaterialRef,
295}
296
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
299#[non_exhaustive]
300pub struct Parachute {
301 pub diameter_m: f64,
303 pub sides: u32,
305 pub line_count: u32,
307 pub line_length_m: f64,
309 pub material: MaterialRef,
311 pub line_material: Option<MaterialRef>,
313}
314
315#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
317#[non_exhaustive]
318pub struct Streamer {
319 pub length_m: f64,
321 pub width_m: f64,
323 pub thickness_m: f64,
325 pub material: MaterialRef,
327}
328
329#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(rename_all = "snake_case")]
332#[non_exhaustive]
333pub enum Shape {
334 Conical,
336 Ogive,
338 Ellipsoid,
340 Parabolic,
342 Haack,
344 Power,
346}
347
348impl Shape {
349 fn parse(word: &str) -> Option<Self> {
351 Some(match word {
352 "CONICAL" => Self::Conical,
353 "OGIVE" => Self::Ogive,
354 "ELLIPSOID" => Self::Ellipsoid,
355 "PARABOLIC" => Self::Parabolic,
356 "HAACK" => Self::Haack,
357 "POWER" => Self::Power,
358 _ => return None,
359 })
360 }
361}
362
363#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
365#[serde(rename_all = "snake_case")]
366#[non_exhaustive]
367pub enum MaterialKind {
368 Bulk,
370 Surface,
372 Line,
374}
375
376impl MaterialKind {
377 fn parse(word: &str) -> Option<Self> {
379 Some(match word {
380 "BULK" => Self::Bulk,
381 "SURFACE" => Self::Surface,
382 "LINE" => Self::Line,
383 _ => return None,
384 })
385 }
386}
387
388#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
390#[non_exhaustive]
391pub struct CatalogMaterial {
392 pub name: String,
394 pub kind: MaterialKind,
396 pub density: f64,
398}
399
400#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
402#[non_exhaustive]
403pub struct MaterialRef {
404 pub name: String,
406 pub kind: MaterialKind,
408 pub density: Option<f64>,
411}
412
413impl MaterialRef {
414 #[must_use]
416 pub fn material(&self) -> Option<hpr_design::Material> {
417 let density = self.density?;
418 Some(match self.kind {
419 MaterialKind::Bulk => hpr_design::Material::bulk(self.name.clone(), density),
420 MaterialKind::Surface => hpr_design::Material::surface(self.name.clone(), density),
421 MaterialKind::Line => hpr_design::Material::line(self.name.clone(), density),
422 })
423 }
424}
425
426impl Catalog {
427 #[must_use]
433 pub fn find(&self, manufacturer: &str, part_number: &str) -> Vec<&Part> {
434 let manufacturer = manufacturer.trim().to_lowercase();
435 let part_number = part_number.trim();
436 self.parts
437 .iter()
438 .filter(|part| {
439 part.manufacturer.trim().to_lowercase() == manufacturer
440 && part.part_number.trim() == part_number
441 })
442 .collect()
443 }
444
445 #[must_use]
450 pub fn search(&self, manufacturer: Option<&str>, text: &str) -> Vec<&Part> {
451 let text = text.trim().to_lowercase();
452 let manufacturer = manufacturer.map(|maker| maker.trim().to_lowercase());
453 self.parts
454 .iter()
455 .filter(|part| {
456 manufacturer
457 .as_ref()
458 .is_none_or(|maker| part.manufacturer.trim().to_lowercase() == *maker)
459 && (part.part_number.to_lowercase().contains(&text)
460 || part.description.to_lowercase().contains(&text))
461 })
462 .collect()
463 }
464
465 #[must_use]
467 pub fn manufacturers(&self) -> Vec<&str> {
468 let mut seen = std::collections::BTreeSet::new();
469 self.parts
470 .iter()
471 .map(|part| part.manufacturer.as_str())
472 .filter(|name| seen.insert(*name))
473 .collect()
474 }
475
476 pub fn extend(&mut self, other: Catalog) {
478 self.parts.extend(other.parts);
479 }
480}
481
482pub fn read(text: &str, file: &str) -> Result<CatalogFile, OrcError> {
495 let depth = crate::ork::document::deepest_nesting(text);
496 if depth > MAX_DEPTH {
497 return Err(OrcError::TooDeep {
498 limit: MAX_DEPTH,
499 depth,
500 });
501 }
502 let document = roxmltree::Document::parse(text).map_err(|error| OrcError::Xml {
503 reason: error.to_string(),
504 })?;
505 let root = document.root_element();
506 if root.tag_name().name() != "OpenRocketComponent" {
507 return Err(OrcError::NotACatalog {
508 root: quote(root.tag_name().name()),
509 });
510 }
511 let mut warnings = Warnings::default();
512 let mut ignored: Vec<String> = Vec::new();
513 let mut lists: [(&str, Option<roxmltree::Node<'_, '_>>, usize); 3] = [
517 ("Version", None, 0),
518 ("Materials", None, 0),
519 ("Components", None, 0),
520 ];
521 for element in root.children().filter(roxmltree::Node::is_element) {
522 let name = element.tag_name().name();
523 match lists.iter_mut().find(|(list, _, _)| *list == name) {
524 Some((_, last, count)) => {
525 *last = Some(element);
526 *count += 1;
527 }
528 None => ignored.push(format!("<{}>", quote(name))),
529 }
530 }
531 for (name, _, count) in &lists {
532 if *count > 1 {
533 let reads = if *name == "Version" {
535 ""
536 } else {
537 ", as OpenRocket reads it"
538 };
539 warnings.push(
540 file,
541 WarningKind::Repeated,
542 format!("<{name}> is stated {count} times; the last was read{reads}"),
543 );
544 }
545 }
546 let [(_, version, _), (_, materials, _), (_, components, _)] = lists;
547 if !ignored.is_empty() {
548 warnings.push(
549 file,
550 WarningKind::Ignored,
551 format!(
552 "{} beside the catalog's lists {} ignored",
553 listed(&ignored),
554 if ignored.len() == 1 { "was" } else { "were" }
555 ),
556 );
557 }
558 let version = version.and_then(|node| match value_text(node, "Version") {
559 Ok(text) => Some(text),
560 Err(why) => {
561 warnings.push(file, WarningKind::Ignored, format!("{why}; ignored"));
562 None
563 }
564 });
565 let (materials, index) = materials
566 .map(|list| read_materials(list, &mut warnings))
567 .unwrap_or_default();
568 let mut parts = Vec::new();
569 if let Some(list) = components {
570 for element in list.children().filter(roxmltree::Node::is_element) {
571 if let Some(part) = read_part(element, file, &materials, &index, &mut warnings) {
572 parts.push(part);
573 }
574 }
575 }
576 Ok(CatalogFile {
577 catalog: Catalog { parts },
578 version,
579 materials,
580 warnings: warnings.finish(file),
581 })
582}
583
584#[derive(Default)]
586struct Warnings {
587 list: Vec<Warning>,
588 more: usize,
589}
590
591impl Warnings {
592 fn push(&mut self, at: &str, kind: WarningKind, message: String) {
593 if self.list.len() < MAX_WARNINGS {
594 self.list.push(Warning {
595 at: at.to_owned(),
596 kind,
597 message,
598 });
599 } else {
600 self.more += 1;
601 }
602 }
603
604 fn finish(mut self, file: &str) -> Vec<Warning> {
605 if self.more > 0 {
606 self.list.push(Warning {
607 at: file.to_owned(),
608 kind: WarningKind::TooMany,
609 message: format!("{} more warnings were not listed", self.more),
610 });
611 }
612 self.list
613 }
614}
615
616fn listed(items: &[String]) -> String {
618 let shown = items.iter().take(5).cloned().collect::<Vec<_>>().join(", ");
619 match items.len() {
620 0..=5 => shown,
621 n => format!("{shown} and {} more", n - 5),
622 }
623}
624
625fn child<'a, 'input>(
627 node: roxmltree::Node<'a, 'input>,
628 name: &str,
629) -> Option<roxmltree::Node<'a, 'input>> {
630 node.children()
631 .find(|child| child.is_element() && child.tag_name().name() == name)
632}
633
634fn text_of(node: roxmltree::Node<'_, '_>) -> String {
636 node.children()
637 .filter(roxmltree::Node::is_text)
638 .filter_map(|text| text.text())
639 .collect()
640}
641
642fn value_text(node: roxmltree::Node<'_, '_>, name: &str) -> Result<String, String> {
644 if node.children().any(|child| child.is_element()) {
645 Err(format!("<{name}> holds an element, not a value"))
646 } else {
647 Ok(text_of(node))
648 }
649}
650
651fn quote(text: &str) -> String {
653 match text.char_indices().nth(QUOTE_LIMIT) {
654 Some((end, _)) => format!("{}…", &text[..end]),
655 None => text.to_owned(),
656 }
657}
658
659fn number(text: &str) -> Option<f64> {
661 text.trim()
662 .parse::<f64>()
663 .ok()
664 .filter(|value| value.is_finite())
665 .map(|value| value + 0.0)
667}
668
669fn length_factor(unit: &str) -> Option<f64> {
674 Some(match unit {
675 "m" => 1.0,
676 "cm" => 0.01,
677 "mm" => 0.001,
678 "in" => INCH_M,
679 "ft" => FOOT_M,
680 _ => return None,
681 })
682}
683
684const POUND_KG: f64 = 0.453_592_37;
686const OUNCE_KG: f64 = POUND_KG / 16.0;
688const INCH_M: f64 = 0.0254;
690const FOOT_M: f64 = 0.3048;
692
693fn mass_factor(unit: &str) -> Option<f64> {
695 Some(match unit {
696 "kg" => 1.0,
697 "g" => 0.001,
698 "oz" => OUNCE_KG,
699 "lb" => POUND_KG,
700 _ => return None,
701 })
702}
703
704fn density_factor(kind: MaterialKind, unit: &str) -> Option<f64> {
707 Some(match (kind, unit) {
708 (MaterialKind::Bulk, "kg/m3") => 1.0,
709 (MaterialKind::Bulk, "g/cm3" | "kg/dm3") => 1000.0,
710 (MaterialKind::Bulk, "lb/ft3") => POUND_KG / (FOOT_M * FOOT_M * FOOT_M),
711 (MaterialKind::Surface, "kg/m2") => 1.0,
712 (MaterialKind::Surface, "g/m2") => 0.001,
713 (MaterialKind::Surface, "g/cm2") => 10.0,
714 (MaterialKind::Surface, "oz/in2") => OUNCE_KG / (INCH_M * INCH_M),
715 (MaterialKind::Surface, "oz/ft2") => OUNCE_KG / (FOOT_M * FOOT_M),
716 (MaterialKind::Surface, "lb/ft2") => POUND_KG / (FOOT_M * FOOT_M),
717 (MaterialKind::Line, "kg/m") => 1.0,
718 (MaterialKind::Line, "g/m") => 0.001,
719 (MaterialKind::Line, "g/cm") => 0.1,
720 (MaterialKind::Line, "oz/ft") => OUNCE_KG / FOOT_M,
721 _ => return None,
722 })
723}
724
725const LIGHTEST_FABRIC_KG_M2: f64 = 0.001;
730
731const LIGHTEST_SOLID_KG_M3: f64 = 1.0;
735
736impl MaterialKind {
737 fn name(self) -> &'static str {
739 match self {
740 Self::Bulk => "bulk",
741 Self::Surface => "surface",
742 Self::Line => "line",
743 }
744 }
745}
746
747fn read_materials(
750 list: roxmltree::Node<'_, '_>,
751 warnings: &mut Warnings,
752) -> (Vec<CatalogMaterial>, MaterialIndex) {
753 let mut materials: Vec<CatalogMaterial> = Vec::new();
754 let mut index = MaterialIndex::new();
755 for element in list.children().filter(roxmltree::Node::is_element) {
756 let name = child(element, "Name").map(text_of).unwrap_or_default();
757 let at = format!("Materials: {}", quote(&name));
758 let left_out = |warnings: &mut Warnings, why: String| {
759 warnings.push(
760 &at,
761 WarningKind::MaterialLeftOut,
762 format!("{why}; it was left out"),
763 );
764 };
765 if element.tag_name().name() != "Material" {
766 let why = format!("<{}> is not a material", quote(element.tag_name().name()));
767 left_out(warnings, why);
768 continue;
769 }
770 if let Some(why) = ["Name", "Type"]
771 .into_iter()
772 .filter_map(|field| child(element, field).map(|node| (field, node)))
773 .find_map(|(field, node)| value_text(node, field).err())
774 {
775 left_out(warnings, why);
776 continue;
777 }
778 let Some(kind) = child(element, "Type")
779 .map(text_of)
780 .and_then(|word| MaterialKind::parse(word.trim()))
781 else {
782 left_out(
783 warnings,
784 "its <Type> is not BULK, SURFACE or LINE".to_owned(),
785 );
786 continue;
787 };
788 let Some((written, value)) = child(element, "Density")
789 .and_then(|node| value_text(node, "Density").ok())
790 .and_then(|text| number(&text).map(|value| (text, value)))
791 else {
792 left_out(
793 warnings,
794 "its <Density> is missing or not a number".to_owned(),
795 );
796 continue;
797 };
798 let unit = element.attribute("UnitsOfMeasure").unwrap_or(match kind {
801 MaterialKind::Bulk => "kg/m3",
802 MaterialKind::Surface => "kg/m2",
803 MaterialKind::Line => "kg/m",
804 });
805 let Some(factor) = density_factor(kind, unit) else {
806 let why = format!(
807 "`{}` is not a unit of {} density this reads",
808 quote(unit),
809 kind.name()
810 );
811 left_out(warnings, why);
812 continue;
813 };
814 let density = value * factor;
815 if !density.is_finite() || density < 0.0 {
816 let why = format!(
817 "its density, {} {}, is not one a material has",
818 quote(written.trim()),
819 quote(unit)
820 );
821 left_out(warnings, why);
822 continue;
823 }
824 if let Some(first) = index
825 .get(&(kind, name.clone()))
826 .and_then(|&first| materials.get(first))
827 && first.density != density
828 {
829 warnings.push(
830 &at,
831 WarningKind::MaterialRepeated,
832 format!(
833 "it is defined again with another density, {density} against {}; parts \
834 take the first",
835 first.density
836 ),
837 );
838 }
839 if kind == MaterialKind::Surface && density < LIGHTEST_FABRIC_KG_M2 {
840 warnings.push(
841 &at,
842 WarningKind::Implausible,
843 format!(
844 "{value} {} is {density} kg/m², lighter than any fabric: the unit is likely \
845 wrong; it was read as written, as OpenRocket reads it",
846 quote(unit)
847 ),
848 );
849 }
850 if kind == MaterialKind::Bulk && density < LIGHTEST_SOLID_KG_M3 {
851 warnings.push(
852 &at,
853 WarningKind::Implausible,
854 format!(
855 "{value} {} is {density} kg/m³, lighter than air: the value or its unit is \
856 likely wrong; it was read as written, as OpenRocket reads it",
857 quote(unit)
858 ),
859 );
860 }
861 index.entry((kind, name.clone())).or_insert(materials.len());
862 materials.push(CatalogMaterial {
863 name,
864 kind,
865 density,
866 });
867 }
868 (materials, index)
869}
870
871type MaterialIndex = std::collections::BTreeMap<(MaterialKind, String), usize>;
873
874struct Skip(String);
876
877const COMMON_FIELDS: &[&str] = &[
879 "Manufacturer",
880 "PartNumber",
881 "Description",
882 "Material",
883 "Mass",
884];
885
886fn read_part(
888 element: roxmltree::Node<'_, '_>,
889 file: &str,
890 materials: &[CatalogMaterial],
891 index: &MaterialIndex,
892 warnings: &mut Warnings,
893) -> Option<Part> {
894 let tag = element.tag_name().name();
895 let mut fields: std::collections::BTreeMap<&str, (roxmltree::Node<'_, '_>, usize)> =
897 std::collections::BTreeMap::new();
898 for node in element.children().filter(roxmltree::Node::is_element) {
899 let entry = fields.entry(node.tag_name().name()).or_insert((node, 0));
900 *entry = (node, entry.1 + 1);
901 }
902 let text = |name: &str| fields.get(name).map(|(node, _)| text_of(*node));
903 let manufacturer = text("Manufacturer").unwrap_or_default();
904 let part_number = text("PartNumber").unwrap_or_default();
905 let at = format!("{} {}", quote(&manufacturer), quote(&part_number));
906 let Some(known) = known_fields(tag) else {
907 warnings.push(
908 &at,
909 WarningKind::PartLeftOut,
910 format!(
911 "<{}> is not a kind of part this reads; it was left out",
912 quote(tag)
913 ),
914 );
915 return None;
916 };
917 let unknown: Vec<String> = fields
918 .keys()
919 .filter(|name| !known.contains(name) && !COMMON_FIELDS.contains(name))
920 .map(|name| format!("<{}>", quote(name)))
921 .collect();
922 if !unknown.is_empty() {
923 warnings.push(
924 &at,
925 WarningKind::Ignored,
926 format!(
927 "{} {} not a field of a <{tag}> OpenRocket reads; ignored",
928 listed(&unknown),
929 if unknown.len() == 1 { "is" } else { "are" }
930 ),
931 );
932 }
933 for (name, (_, count)) in &fields {
934 if *count > 1 && (known.contains(name) || COMMON_FIELDS.contains(name)) {
935 warnings.push(
936 &at,
937 WarningKind::Repeated,
938 format!(
939 "<{name}> is stated {count} times; the last was read, as OpenRocket reads it"
940 ),
941 );
942 }
943 }
944 let get = |name: &str| fields.get(name).map(|(node, _)| *node);
945 match part(tag, &get, materials, index) {
946 Ok((kind, mass_kg)) => {
947 for material in kind.materials() {
948 if material.density.is_none() {
949 warnings.push(
950 &at,
951 WarningKind::MaterialUndefined,
952 format!(
953 "its material `{}` is not defined in this file, so it has no density",
954 quote(&material.name)
955 ),
956 );
957 }
958 }
959 if let Some(tube) = kind.tube()
960 && tube.inner_diameter_m >= tube.outer_diameter_m
961 {
962 warnings.push(
963 &at,
964 WarningKind::Implausible,
965 format!(
966 "its inside diameter, {} m, is not less than its outside diameter, {} \
967 m, so its wall is {} m; read as written, as OpenRocket reads it",
968 tube.inner_diameter_m,
969 tube.outer_diameter_m,
970 tube.thickness_m()
971 ),
972 );
973 }
974 Some(Part {
975 file: file.to_owned(),
976 manufacturer,
977 part_number,
978 description: text("Description").unwrap_or_default(),
979 mass_kg,
980 kind,
981 })
982 }
983 Err(Skip(reason)) => {
984 warnings.push(
985 &at,
986 WarningKind::PartLeftOut,
987 format!("{reason}; the part was left out"),
988 );
989 None
990 }
991 }
992}
993
994fn known_fields(tag: &str) -> Option<&'static [&'static str]> {
997 Some(match tag {
998 "BodyTube" | "TubeCoupler" | "EngineBlock" | "CenteringRing" | "LaunchLug" => {
999 &["InsideDiameter", "OutsideDiameter", "Length"]
1000 }
1001 "BulkHead" => &["OutsideDiameter", "Length", "Filled"],
1002 "NoseCone" => &[
1003 "Shape",
1004 "OutsideDiameter",
1005 "ShoulderDiameter",
1006 "ShoulderLength",
1007 "Length",
1008 "Filled",
1009 "Thickness",
1010 ],
1011 "Transition" => &[
1012 "Shape",
1013 "ForeOutsideDiameter",
1014 "ForeShoulderDiameter",
1015 "ForeShoulderLength",
1016 "AftOutsideDiameter",
1017 "AftShoulderDiameter",
1018 "AftShoulderLength",
1019 "Length",
1020 "Filled",
1021 "Thickness",
1022 ],
1023 "Parachute" => &[
1024 "Diameter",
1025 "Sides",
1026 "LineCount",
1027 "LineLength",
1028 "LineMaterial",
1029 ],
1030 "Streamer" => &["Width", "Length", "Thickness"],
1031 _ => return None,
1032 })
1033}
1034
1035impl PartKind {
1036 #[must_use]
1038 pub fn tube(&self) -> Option<&Tube> {
1039 match self {
1040 Self::BodyTube(tube)
1041 | Self::TubeCoupler(tube)
1042 | Self::EngineBlock(tube)
1043 | Self::CenteringRing(tube)
1044 | Self::LaunchLug(tube) => Some(tube),
1045 _ => None,
1046 }
1047 }
1048
1049 #[must_use]
1051 pub fn materials(&self) -> Vec<&MaterialRef> {
1052 match self {
1053 Self::BodyTube(tube)
1054 | Self::TubeCoupler(tube)
1055 | Self::EngineBlock(tube)
1056 | Self::CenteringRing(tube)
1057 | Self::LaunchLug(tube) => vec![&tube.material],
1058 Self::Bulkhead(bulkhead) => vec![&bulkhead.material],
1059 Self::NoseCone(nose) => vec![&nose.material],
1060 Self::Transition(transition) => vec![&transition.material],
1061 Self::Parachute(parachute) => {
1062 let mut list = vec![¶chute.material];
1063 list.extend(parachute.line_material.as_ref());
1064 list
1065 }
1066 Self::Streamer(streamer) => vec![&streamer.material],
1067 }
1068 }
1069}
1070
1071fn part<'a, 'input: 'a>(
1073 tag: &str,
1074 get: &dyn Fn(&str) -> Option<roxmltree::Node<'a, 'input>>,
1075 materials: &[CatalogMaterial],
1076 index: &MaterialIndex,
1077) -> Result<(PartKind, Option<f64>), Skip> {
1078 if get("Manufacturer").is_none() {
1079 return Err(Skip("it names no <Manufacturer>".to_owned()));
1080 }
1081 if get("PartNumber").is_none() {
1082 return Err(Skip("it has no <PartNumber>".to_owned()));
1083 }
1084 for name in ["Manufacturer", "PartNumber", "Description"] {
1087 if let Some(node) = get(name) {
1088 value_text(node, name).map_err(Skip)?;
1089 }
1090 }
1091 let mass_kg = get("Mass")
1092 .map(|node| measure(node, "Mass", mass_factor))
1093 .transpose()?;
1094 let length = |name: &str| -> Result<f64, Skip> {
1095 let node = get(name).ok_or_else(|| Skip(format!("it has no <{name}>")))?;
1096 measure(node, name, length_factor)
1097 };
1098 let optional_length = |name: &str| -> Result<Option<f64>, Skip> {
1099 get(name)
1100 .map(|node| measure(node, name, length_factor))
1101 .transpose()
1102 };
1103 let material = |name: &str, want: MaterialKind| -> Result<MaterialRef, Skip> {
1104 let node = get(name).ok_or_else(|| Skip(format!("it names no <{name}>")))?;
1105 material_ref(node, name, want, materials, index)
1106 };
1107 let word = |name: &str| -> Result<Option<String>, Skip> {
1108 get(name)
1109 .map(|node| value_text(node, name).map_err(Skip))
1110 .transpose()
1111 };
1112 let filled = || -> Result<Option<bool>, Skip> {
1113 word("Filled")?
1114 .map(|text| match text.trim() {
1115 "true" | "1" => Ok(true),
1116 "false" | "0" => Ok(false),
1117 other => Err(Skip(format!(
1118 "<Filled> is `{}`, not true or false",
1119 quote(other)
1120 ))),
1121 })
1122 .transpose()
1123 };
1124 let shape = || -> Result<Shape, Skip> {
1125 let text = word("Shape")?.ok_or_else(|| Skip("it has no <Shape>".to_owned()))?;
1126 Shape::parse(text.trim()).ok_or_else(|| {
1127 Skip(format!(
1128 "<Shape> is `{}`, not one of CONICAL, OGIVE, ELLIPSOID, PARABOLIC, HAACK or POWER",
1129 quote(&text)
1130 ))
1131 })
1132 };
1133 let count = |name: &str| -> Result<u32, Skip> {
1134 let text = word(name)?.ok_or_else(|| Skip(format!("it has no <{name}>")))?;
1135 text.trim().parse::<u32>().map_err(|_| {
1136 Skip(format!(
1137 "<{name}> is `{}`, not a whole number",
1138 quote(&text)
1139 ))
1140 })
1141 };
1142 let tube = || -> Result<Tube, Skip> {
1143 Ok(Tube {
1144 inner_diameter_m: length("InsideDiameter")?,
1145 outer_diameter_m: length("OutsideDiameter")?,
1146 length_m: length("Length")?,
1147 material: material("Material", MaterialKind::Bulk)?,
1148 })
1149 };
1150 let kind = match tag {
1151 "BodyTube" => PartKind::BodyTube(tube()?),
1152 "TubeCoupler" => PartKind::TubeCoupler(tube()?),
1153 "EngineBlock" => PartKind::EngineBlock(tube()?),
1154 "CenteringRing" => PartKind::CenteringRing(tube()?),
1155 "LaunchLug" => PartKind::LaunchLug(tube()?),
1156 "BulkHead" => PartKind::Bulkhead(Bulkhead {
1157 outer_diameter_m: length("OutsideDiameter")?,
1158 length_m: length("Length")?,
1159 filled: filled()?,
1160 material: material("Material", MaterialKind::Bulk)?,
1161 }),
1162 "NoseCone" => PartKind::NoseCone(NoseCone {
1163 shape: shape()?,
1164 length_m: length("Length")?,
1165 outer_diameter_m: length("OutsideDiameter")?,
1166 shoulder_diameter_m: length("ShoulderDiameter")?,
1167 shoulder_length_m: length("ShoulderLength")?,
1168 filled: filled()?,
1169 thickness_m: optional_length("Thickness")?,
1170 material: material("Material", MaterialKind::Bulk)?,
1171 }),
1172 "Transition" => PartKind::Transition(Transition {
1173 shape: shape()?,
1174 length_m: length("Length")?,
1175 fore_outer_diameter_m: length("ForeOutsideDiameter")?,
1176 fore_shoulder_diameter_m: length("ForeShoulderDiameter")?,
1177 fore_shoulder_length_m: length("ForeShoulderLength")?,
1178 aft_outer_diameter_m: length("AftOutsideDiameter")?,
1179 aft_shoulder_diameter_m: length("AftShoulderDiameter")?,
1180 aft_shoulder_length_m: length("AftShoulderLength")?,
1181 filled: filled()?,
1182 thickness_m: optional_length("Thickness")?,
1183 material: material("Material", MaterialKind::Bulk)?,
1184 }),
1185 "Parachute" => PartKind::Parachute(Parachute {
1186 diameter_m: length("Diameter")?,
1187 sides: count("Sides")?,
1188 line_count: count("LineCount")?,
1189 line_length_m: length("LineLength")?,
1190 material: material("Material", MaterialKind::Surface)?,
1191 line_material: get("LineMaterial")
1192 .map(|node| {
1193 material_ref(node, "LineMaterial", MaterialKind::Line, materials, index)
1194 })
1195 .transpose()?,
1196 }),
1197 "Streamer" => PartKind::Streamer(Streamer {
1198 length_m: length("Length")?,
1199 width_m: length("Width")?,
1200 thickness_m: length("Thickness")?,
1201 material: material("Material", MaterialKind::Surface)?,
1202 }),
1203 _ => return Err(Skip(format!("<{}> is not a kind of part", quote(tag)))),
1205 };
1206 Ok((kind, mass_kg))
1207}
1208
1209fn measure(
1212 node: roxmltree::Node<'_, '_>,
1213 name: &str,
1214 factor: fn(&str) -> Option<f64>,
1215) -> Result<f64, Skip> {
1216 let text = value_text(node, name).map_err(Skip)?;
1217 let value = number(&text)
1218 .ok_or_else(|| Skip(format!("<{name}> is `{}`, not a number", quote(&text))))?;
1219 if value < 0.0 {
1220 return Err(Skip(format!("<{name}> is {value}, less than zero")));
1221 }
1222 let scale = match node.attribute("Unit") {
1223 None => 1.0,
1224 Some(unit) => factor(unit).ok_or_else(|| {
1225 Skip(format!(
1226 "<{name}>'s unit `{}` is not one this reads",
1227 quote(unit)
1228 ))
1229 })?,
1230 };
1231 Ok(value * scale)
1232}
1233
1234fn material_ref(
1238 node: roxmltree::Node<'_, '_>,
1239 name: &str,
1240 want: MaterialKind,
1241 materials: &[CatalogMaterial],
1242 index: &MaterialIndex,
1243) -> Result<MaterialRef, Skip> {
1244 let kind = node
1245 .attribute("Type")
1246 .and_then(MaterialKind::parse)
1247 .ok_or_else(|| Skip(format!("<{name}>'s Type is not BULK, SURFACE or LINE")))?;
1248 if kind != want {
1249 return Err(Skip(format!(
1250 "<{name}> names a material of the wrong kind for the part"
1251 )));
1252 }
1253 let material_name = value_text(node, name).map_err(Skip)?;
1254 let density = index
1255 .get(&(kind, material_name.clone()))
1256 .and_then(|&first| materials.get(first))
1257 .map(|material| material.density);
1258 Ok(MaterialRef {
1259 name: material_name,
1260 kind,
1261 density,
1262 })
1263}