Skip to main content

hpr_io/orc/
mod.rs

1//! OpenRocket's `.orc` parts catalogs: nose cones, tubes, rings, transitions, parachutes and
2//! streamers as their makers sell them, by maker and part number.
3//!
4//! A `.orc` file is XML: an `<OpenRocketComponent>` root holding a `<Version>`, a `<Materials>`
5//! list (each a name, a density, the density's units and its kind: `BULK`, `SURFACE` or `LINE`)
6//! and a `<Components>` list of parts. A part names its maker, part number and description, its
7//! dimensions each with its own `Unit` attribute, and its material by name and kind; the material
8//! must be defined in the same file. The format has no published schema; its field list and units
9//! are the ones the `openrocket-database` project documents (`docs/TechnicalInfo.md` at the pinned
10//! commit), and what each field means is what OpenRocket 24.12's preset loader makes of it,
11//! measured by running that loader on the same files as an oracle
12//! (`validation/oracles/openrocket/orc_presets.py`).
13//!
14//! [`read`] reads one file into a [`Catalog`]. [`bundled`] is the catalog OpenRocket 24.12
15//! ships: the 16 files of `openrocket-database` (Apache-2.0) at the commit
16//! `validation/refs.lock.toml` pins, compiled into this crate unchanged, 3,449 parts.
17//! [`Catalog::find`] looks a part up by maker and part number.
18//!
19//! **What is read, and how far it agrees with OpenRocket.** Every value is converted to SI as the
20//! file states it: lengths in meters, masses in kilograms, densities in kg/m³, kg/m² or kg/m. On
21//! the bundled files, every part OpenRocket reads is read here, in the same order, and every
22//! value equals OpenRocket's bit for bit (17,911 of 18,306 numbers), with four exceptions that the
23//! test `crates/hpr-io/tests/orc_openrocket.rs` counts:
24//!
25//! - **Ounces.** OpenRocket's ounce is 0.0283495231 kg; the avoirdupois ounce is exactly
26//!   0.028349523125 kg (NIST Handbook 44, Appendix C), and that is the one used here. The
27//!   difference is 9e-10 of the value, on the 185 masses stated in ounces. OpenRocket rounds a
28//!   few other imperial factors the same way (`lb/ft³`, `oz/in²`, `oz/ft²`, `lb/ft²`, `oz/ft`);
29//!   no bundled file uses them.
30//! - **Makers' names.** OpenRocket shows two makers under names of its own: "LOC Precision" as
31//!   "LOC/Precision" and "Public Missiles" as "Public Missiles, Ltd." The name here is the file's.
32//! - **A stated mass.** When a solid part states its mass, OpenRocket replaces its material's
33//!   density with the one that gives the part that mass. Here the material keeps the file's
34//!   density and the part keeps its mass ([`Part::mass_kg`]), which a design built from the part
35//!   by hand can set as its mass. This is 207 parts; with the masses taken out of the files,
36//!   OpenRocket's density is the one read here on every one of them. A parachute's or streamer's
37//!   stated mass leaves its fabric's density alone, here and in OpenRocket.
38//! - **Undefined materials.** A material the file names but doesn't define has no density here
39//!   ([`MaterialRef::density`] is `None`); OpenRocket gives it zero. This is 3 parts.
40//!
41//! **What the file leaves unsaid.** No `.orc` field gives a nose or transition shape's parameter
42//! (an ogive's radius, a Haack series' `C`), a shoulder's wall, or a parachute's drag coefficient;
43//! OpenRocket uses its own defaults for these when a part is put in a design. A part's material
44//! is only as good as the file's density, and the database's own README warns to weigh real parts.
45//!
46//! **Warnings.** A part or material that can't be read is left out with a [`Warning`] saying why,
47//! rather than failing the file; so are fields ignored or repeated, and values read as written but
48//! implausible (an inside diameter not less than the outside, a solid lighter than air, a fabric
49//! under 1 g/m²). The bundled files give 55.
50
51mod bundled;
52#[cfg(test)]
53mod tests;
54
55pub use bundled::{BUNDLED_FILES, bundled};
56
57use serde::{Deserialize, Serialize};
58
59/// The most characters of a file's text a warning or an error quotes.
60const QUOTE_LIMIT: usize = 64;
61
62/// A `.orc` file that can't be read at all.
63#[derive(Debug, Clone, PartialEq, thiserror::Error)]
64#[non_exhaustive]
65pub enum OrcError {
66    /// The elements nest deeper than [`MAX_DEPTH`]. Checked before the XML is parsed, as the
67    /// parser descends the tree and a file nested deep enough would exhaust the stack.
68    #[error("the elements nest {depth} deep, past the {limit} this reads")]
69    TooDeep {
70        /// [`MAX_DEPTH`].
71        limit: usize,
72        /// The nesting found, or more.
73        depth: usize,
74    },
75    /// The text is not well-formed XML.
76    #[error("not well-formed XML: {reason}")]
77    Xml {
78        /// The XML parser's message.
79        reason: String,
80    },
81    /// The root element is not `<OpenRocketComponent>`.
82    #[error("the root element is <{root}>, not <OpenRocketComponent>: not a parts catalog")]
83    NotACatalog {
84        /// The root element's name, shortened.
85        root: String,
86    },
87}
88
89/// The deepest nesting read. A `.orc` nests four deep (the root, the parts list, a part, its
90/// field); the parser below has been seen to exhaust a 2 MiB stack at 130 levels (the `.ork`
91/// reader's measurement, [`crate::ork::MAX_DEPTH`]).
92pub const MAX_DEPTH: usize = 16;
93
94/// The most warnings one file gives; past it, one last warning says how many more there were.
95pub const MAX_WARNINGS: usize = 1000;
96
97/// What a [`Warning`] is about.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100#[non_exhaustive]
101pub enum WarningKind {
102    /// A part was left out: a size missing, unreadable or below zero, a unit or shape the reader
103    /// doesn't take, a material of the wrong kind, a value holding an element, or an element that
104    /// isn't a kind of part.
105    PartLeftOut,
106    /// A material was left out: an unknown unit or kind, no density, a density below zero or too
107    /// large for an `f64`, or a name or kind holding an element.
108    MaterialLeftOut,
109    /// A part names a material its file doesn't define; it has no density.
110    MaterialUndefined,
111    /// A file defines two materials of the same name and kind with different densities; parts
112    /// take the first.
113    MaterialRepeated,
114    /// Fields a part's kind doesn't have, elements beside the catalog's lists, or a `<Version>`
115    /// holding an element, were ignored.
116    Ignored,
117    /// A field or a list was stated more than once; the last was read.
118    Repeated,
119    /// A value read as written that can't be right: a wall of no thickness or less, a solid
120    /// lighter than air, or a fabric lighter than any made.
121    Implausible,
122    /// [`MAX_WARNINGS`] were given; the rest are counted, not listed.
123    TooMany,
124}
125
126/// Something read differently from what the file says, or left out. Reading goes on.
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128#[non_exhaustive]
129pub struct Warning {
130    /// Where: the part's maker and number, `Materials: ` and a material's name, or the file.
131    pub at: String,
132    /// What kind of warning.
133    pub kind: WarningKind,
134    /// What happened, in a sentence.
135    pub message: String,
136}
137
138/// A catalog file read, with what was left out of it.
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140#[non_exhaustive]
141pub struct CatalogFile {
142    /// The parts read, in the file's order.
143    pub catalog: Catalog,
144    /// The file's `<Version>`, as written; `None` if it has none.
145    pub version: Option<String>,
146    /// The materials the file defines, in its order.
147    pub materials: Vec<CatalogMaterial>,
148    /// Parts left out and values read differently, one entry each, at most [`MAX_WARNINGS`] and
149    /// one more.
150    pub warnings: Vec<Warning>,
151}
152
153/// Parts, from one catalog file or several.
154#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
155pub struct Catalog {
156    /// Every part, in file order.
157    pub parts: Vec<Part>,
158}
159
160/// One part as its catalog lists it.
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[non_exhaustive]
163pub struct Part {
164    /// The catalog file it came from, such as `estes_classic.orc`.
165    pub file: String,
166    /// The maker, as the file writes it.
167    pub manufacturer: String,
168    /// The maker's part number, as the file writes it.
169    pub part_number: String,
170    /// The file's description; empty if it has none. A part that states two keeps the last, as
171    /// OpenRocket does.
172    pub description: String,
173    /// The part's mass as the file states it, kg; `None` for most parts, whose mass follows from
174    /// their size and material.
175    pub mass_kg: Option<f64>,
176    /// What kind of part, with its dimensions.
177    pub kind: PartKind,
178}
179
180/// A part's kind and dimensions.
181///
182/// The five tube-like kinds share [`Tube`]'s fields, as the format gives them: an inner and
183/// outer diameter and a length (a centering ring's length is its thickness).
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185#[serde(tag = "kind", rename_all = "snake_case")]
186#[non_exhaustive]
187pub enum PartKind {
188    /// An airframe tube (`<BodyTube>`).
189    BodyTube(Tube),
190    /// A coupler that joins two tubes (`<TubeCoupler>`); an inner diameter of zero makes it a
191    /// solid nose block.
192    TubeCoupler(Tube),
193    /// A ring that stops the motor moving forward (`<EngineBlock>`).
194    EngineBlock(Tube),
195    /// A ring that holds an inner tube in an outer one (`<CenteringRing>`).
196    CenteringRing(Tube),
197    /// A launch lug (`<LaunchLug>`).
198    LaunchLug(Tube),
199    /// A solid disc (`<BulkHead>`).
200    Bulkhead(Bulkhead),
201    /// A nose cone (`<NoseCone>`).
202    NoseCone(NoseCone),
203    /// A transition between two diameters (`<Transition>`).
204    Transition(Transition),
205    /// A parachute (`<Parachute>`).
206    Parachute(Parachute),
207    /// A streamer (`<Streamer>`).
208    Streamer(Streamer),
209}
210
211/// A tube-like part: a body tube, coupler, engine block, centering ring or launch lug.
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213#[non_exhaustive]
214pub struct Tube {
215    /// Inner diameter, m.
216    pub inner_diameter_m: f64,
217    /// Outer diameter, m.
218    pub outer_diameter_m: f64,
219    /// Length, m.
220    pub length_m: f64,
221    /// Material (bulk).
222    pub material: MaterialRef,
223}
224
225impl Tube {
226    /// The wall's thickness, m: `(outer diameter − inner diameter)/2`, as OpenRocket works it out.
227    #[must_use]
228    pub fn thickness_m(&self) -> f64 {
229        (self.outer_diameter_m - self.inner_diameter_m) / 2.0
230    }
231}
232
233/// A bulkhead: a solid disc.
234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
235#[non_exhaustive]
236pub struct Bulkhead {
237    /// Outer diameter, m.
238    pub outer_diameter_m: f64,
239    /// Length (thickness), m.
240    pub length_m: f64,
241    /// `<Filled>` as written; `None` if absent. A bulkhead is solid either way.
242    pub filled: Option<bool>,
243    /// Material (bulk).
244    pub material: MaterialRef,
245}
246
247/// A nose cone.
248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
249#[non_exhaustive]
250pub struct NoseCone {
251    /// Profile shape. The file gives no shape parameter.
252    pub shape: Shape,
253    /// Length from tip to base, not counting the shoulder, m.
254    pub length_m: f64,
255    /// Base diameter, m.
256    pub outer_diameter_m: f64,
257    /// Shoulder diameter, m; zero for no shoulder.
258    pub shoulder_diameter_m: f64,
259    /// Shoulder length, m; zero for no shoulder.
260    pub shoulder_length_m: f64,
261    /// `<Filled>` as written; `None` if absent.
262    pub filled: Option<bool>,
263    /// Wall thickness, m; `None` if absent.
264    pub thickness_m: Option<f64>,
265    /// Material (bulk).
266    pub material: MaterialRef,
267}
268
269/// A transition.
270#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
271#[non_exhaustive]
272pub struct Transition {
273    /// Profile shape. The file gives no shape parameter.
274    pub shape: Shape,
275    /// Length, not counting shoulders, m.
276    pub length_m: f64,
277    /// Forward outer diameter, m.
278    pub fore_outer_diameter_m: f64,
279    /// Forward shoulder diameter, m; zero for none.
280    pub fore_shoulder_diameter_m: f64,
281    /// Forward shoulder length, m; zero for none.
282    pub fore_shoulder_length_m: f64,
283    /// Aft outer diameter, m.
284    pub aft_outer_diameter_m: f64,
285    /// Aft shoulder diameter, m; zero for none.
286    pub aft_shoulder_diameter_m: f64,
287    /// Aft shoulder length, m; zero for none.
288    pub aft_shoulder_length_m: f64,
289    /// `<Filled>` as written; `None` if absent.
290    pub filled: Option<bool>,
291    /// Wall thickness, m; `None` if absent.
292    pub thickness_m: Option<f64>,
293    /// Material (bulk).
294    pub material: MaterialRef,
295}
296
297/// A parachute.
298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
299#[non_exhaustive]
300pub struct Parachute {
301    /// Canopy diameter, m.
302    pub diameter_m: f64,
303    /// The canopy's number of sides.
304    pub sides: u32,
305    /// Number of shroud lines.
306    pub line_count: u32,
307    /// Length of each shroud line, m.
308    pub line_length_m: f64,
309    /// Canopy fabric (surface).
310    pub material: MaterialRef,
311    /// Shroud-line cord (line); `None` if the file names none.
312    pub line_material: Option<MaterialRef>,
313}
314
315/// A streamer.
316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
317#[non_exhaustive]
318pub struct Streamer {
319    /// Length, m.
320    pub length_m: f64,
321    /// Width, m.
322    pub width_m: f64,
323    /// Thickness, m. The database notes OpenRocket may make no use of it.
324    pub thickness_m: f64,
325    /// Material (surface).
326    pub material: MaterialRef,
327}
328
329/// A nose cone's or transition's profile, as the file names it.
330#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(rename_all = "snake_case")]
332#[non_exhaustive]
333pub enum Shape {
334    /// `CONICAL`.
335    Conical,
336    /// `OGIVE`.
337    Ogive,
338    /// `ELLIPSOID`.
339    Ellipsoid,
340    /// `PARABOLIC`: a parabolic series.
341    Parabolic,
342    /// `HAACK`: a Haack series.
343    Haack,
344    /// `POWER`: a power series.
345    Power,
346}
347
348impl Shape {
349    /// The shape named by `word`, as the format spells it.
350    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/// The kind of density a material has.
364#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
365#[serde(rename_all = "snake_case")]
366#[non_exhaustive]
367pub enum MaterialKind {
368    /// Mass per volume, kg/m³: anything solid.
369    Bulk,
370    /// Mass per area, kg/m²: canopy and streamer fabric.
371    Surface,
372    /// Mass per length, kg/m: shroud lines.
373    Line,
374}
375
376impl MaterialKind {
377    /// The kind named by `word`, as the format spells it.
378    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/// A material a catalog file defines.
389#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
390#[non_exhaustive]
391pub struct CatalogMaterial {
392    /// Its name.
393    pub name: String,
394    /// Its kind.
395    pub kind: MaterialKind,
396    /// Its density in SI units of its kind: kg/m³, kg/m² or kg/m.
397    pub density: f64,
398}
399
400/// The material a part names, with the density its file defines for it.
401#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
402#[non_exhaustive]
403pub struct MaterialRef {
404    /// The name the part gives.
405    pub name: String,
406    /// The kind the part gives.
407    pub kind: MaterialKind,
408    /// The density in SI units of its kind, from the first material of that name and kind the
409    /// file defines; `None` if it defines none.
410    pub density: Option<f64>,
411}
412
413impl MaterialRef {
414    /// The material as `hpr_design` holds it, or `None` if the file doesn't define it.
415    #[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    /// Every part whose maker is `manufacturer` and whose part number is `part_number`, in
428    /// catalog order. Both are compared with surrounding spaces trimmed, the maker in any case
429    /// and the part number exactly. The maker is the file's name for it, not OpenRocket's display
430    /// name ("LOC Precision", not "LOC/Precision"). Most numbers name one part; in the bundled
431    /// catalog, 21 numbers name two parts of the same kind, and 3 of those pairs are identical.
432    #[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    /// Every part whose part number or description holds `text`, in any case, from the maker
446    /// `manufacturer` (in any case) or from any maker if `None`, in catalog order; empty
447    /// `text` matches every part. Part numbers are often several numbers in one, such as Estes'
448    /// `BT-20, 30316`, which `find` matches only whole; a search for `30316` finds it.
449    #[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    /// The makers, each once, in the order they first appear.
466    #[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    /// Adds another catalog's parts after this one's.
477    pub fn extend(&mut self, other: Catalog) {
478        self.parts.extend(other.parts);
479    }
480}
481
482/// Reads one `.orc` file's text; `file` names it in each [`Part::file`].
483///
484/// Only text nested past [`MAX_DEPTH`], text that isn't XML, or a root that isn't
485/// `<OpenRocketComponent>` is refused. A part this can't read (a missing or unreadable size, a
486/// unit or shape it doesn't take, a material of the wrong kind, an element that isn't a kind of
487/// part) is left out with a warning, where OpenRocket 24.12 refuses the whole file for most of
488/// these. Fields a part's kind doesn't have are ignored with a warning, as is anything beside the
489/// three lists; a list stated twice keeps the last, as OpenRocket does, with a warning.
490///
491/// # Errors
492///
493/// [`OrcError::TooDeep`], [`OrcError::Xml`] and [`OrcError::NotACatalog`].
494pub 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    // Each list by name, the last of a repeated one kept: OpenRocket 24.12 reads only the last
514    // `<Materials>` and the last `<Components>` (the oracle's `second materials` and `second
515    // components` probes).
516    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            // The oracle's probes show it for the two lists, not for `<Version>`.
534            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/// A file's warnings, up to [`MAX_WARNINGS`], and a count of the rest.
585#[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
616/// Up to the first five of `items`, joined, and how many more.
617fn 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
625/// The first child element named `name`.
626fn 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
634/// An element's text, every text node joined, as written.
635fn 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
642/// A value's text, or why it has none: an element inside a value is not part of the format.
643fn 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
651/// `text` cut to [`QUOTE_LIMIT`] characters, marked when cut.
652fn 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
659/// A number as the format writes one: a finite decimal, spaces around it allowed.
660fn number(text: &str) -> Option<f64> {
661    text.trim()
662        .parse::<f64>()
663        .ok()
664        .filter(|value| value.is_finite())
665        // `-0` is zero.
666        .map(|value| value + 0.0)
667}
668
669/// The SI factor of a length unit. These are the length units OpenRocket 24.12 reads in a
670/// `.orc` (`docs/TechnicalInfo.md`'s list less `in/64`, which OpenRocket reads as inches); each
671/// factor is its exact definition (NIST Handbook 44, Appendix C: 1 in = 0.0254 m,
672/// 1 ft = 0.3048 m).
673fn 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
684/// The avoirdupois pound, kg (NIST Handbook 44, Appendix C).
685const POUND_KG: f64 = 0.453_592_37;
686/// The avoirdupois ounce, a sixteenth of a pound, kg.
687const OUNCE_KG: f64 = POUND_KG / 16.0;
688/// The inch, m.
689const INCH_M: f64 = 0.0254;
690/// The foot, m.
691const FOOT_M: f64 = 0.3048;
692
693/// The SI factor of a mass unit (exact definitions, as [`length_factor`]).
694fn 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
704/// The SI factor of a density unit of `kind`: the units `docs/TechnicalInfo.md` lists for each
705/// kind that OpenRocket 24.12 reads (it refuses a file with `oz/in`), from the exact definitions.
706fn 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
725/// The lightest fabric this reads without a warning, kg/m²: 1 g/m². The lightest correctly
726/// labelled in the bundled files is 0.3 mil polyethylene film at 7.05 g/m²; a canopy under 1 g/m²
727/// is a unit written wrong, as the six `g/m2` ripstop nylons of the bundled files are (0.067 g/m²
728/// for a fabric of about 67 g/m²).
729const LIGHTEST_FABRIC_KG_M2: f64 = 0.001;
730
731/// The lightest solid this reads without a warning, kg/m³: 1 kg/m³, below air at sea level
732/// (1.225 kg/m³). Two bundled files give a paper `0.0011` kg/m³, where paper is roughly 1,000
733/// kg/m³: the value or its unit is wrong.
734const LIGHTEST_SOLID_KG_M3: f64 = 1.0;
735
736impl MaterialKind {
737    /// The kind's name, for messages.
738    fn name(self) -> &'static str {
739        match self {
740            Self::Bulk => "bulk",
741            Self::Surface => "surface",
742            Self::Line => "line",
743        }
744    }
745}
746
747/// The `<Materials>` list. A material that can't be read is left out with a warning, so parts
748/// naming it read with no density.
749fn 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        // A density with no units is read in SI, as OpenRocket 24.12 reads it (the oracle's
799        // `no units` probe).
800        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
871/// Where each material is in a file's list, by kind and name: the first of a name and kind.
872type MaterialIndex = std::collections::BTreeMap<(MaterialKind, String), usize>;
873
874/// Why a part was left out.
875struct Skip(String);
876
877/// The fields every kind of part has.
878const COMMON_FIELDS: &[&str] = &[
879    "Manufacturer",
880    "PartNumber",
881    "Description",
882    "Material",
883    "Mass",
884];
885
886/// One part, or `None` (with a warning) if it can't be read.
887fn 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    // Each field by name, the last of a repeated one kept, as OpenRocket keeps it.
896    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
994/// The fields OpenRocket 24.12 reads for a kind of part, besides [`COMMON_FIELDS`]; `None` for an
995/// element that isn't a kind of part.
996fn 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    /// The tube-like part's sizes, for the five tube-like kinds.
1037    #[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    /// The materials the part names: one, or a parachute's canopy and its lines.
1050    #[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![&parachute.material];
1063                list.extend(parachute.line_material.as_ref());
1064                list
1065            }
1066            Self::Streamer(streamer) => vec![&streamer.material],
1067        }
1068    }
1069}
1070
1071/// A part's kind and stated mass; `get` finds a field by name.
1072fn 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    // OpenRocket reads only the text after the last element inside a value (the oracle's
1085    // `element in number` probe: `BT<b>-</b>20` is `20`); such a value is not read here.
1086    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        // `known_fields` let through only the kinds above.
1204        _ => return Err(Skip(format!("<{}> is not a kind of part", quote(tag)))),
1205    };
1206    Ok((kind, mass_kg))
1207}
1208
1209/// A size in SI: its number times its `Unit`'s factor. A size with no unit is in SI, as
1210/// OpenRocket 24.12 reads it (the oracle's `no units` probe).
1211fn 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
1234/// The material a part names: its name and kind from the part, its density from the first
1235/// material of that name and kind in the file. A part that names a material as another kind than
1236/// it needs is left out (OpenRocket reads it with a density of zero; no bundled part does it).
1237fn 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}