Skip to main content

hpr_io/ork/
value.rs

1//! Reading the values a `.ork` writes inside its elements.
2//!
3//! Every later step walks the document tree and asks it for numbers, counts, flags and
4//! dimensions. Three things make that less simple than it sounds, and all three are mistakes Loft
5//! made:
6//!
7//! - **A dimension may be automatic.** `<outerradius>auto 0.0125</outerradius>` means "OpenRocket
8//!   works this out from the neighbours, and 0.0125 m is what it last worked out". Keeping only
9//!   the number turns an automatic dimension into a hand-typed one the next time the design is
10//!   saved ([Loft lesson L58][lessons]). [`Dimension`] keeps both.
11//! - **A tag may have two names.** OpenRocket renamed several, and writes both for older readers.
12//!   The reader takes the first name it finds, newest first ([Loft lesson L62][lessons]).
13//! - **A stated zero is a value.** `<overridecd>0.0</overridecd>` means no drag at all, not "no
14//!   override". Loft read it as missing and charged the part full drag
15//!   ([Loft lesson L63][lessons]).
16//!
17//! [lessons]: https://github.com/nrdptel/hpr-sim/blob/main/docs/research/loft-lessons.md
18
19use serde::{Deserialize, Serialize};
20
21use super::document::Element;
22use super::warning::{Warning, WarningKind};
23
24/// The two names OpenRocket writes a component's distance along its parent under.
25///
26/// `position` carries a `type` attribute and `axialoffset` a `method`, with the same vocabulary
27/// (`top`, `middle`, `bottom`, `after`, `absolute`), and **Observed:** on all 642 elements of the
28/// reference corpus that carry both, the two agree on the text *and* on that attribute.
29pub const AXIAL_OFFSET: [&str; 2] = ["axialoffset", "position"];
30
31/// The two names for how many of an instanced component there are (fins, rail buttons, pods).
32///
33/// **Observed:** 109 elements carry both, agreeing every time. Neither carries an attribute, so a
34/// count is the one value here that a rename cannot change the meaning of.
35pub const INSTANCE_COUNT: [&str; 2] = ["instancecount", "fincount"];
36
37/// The flags that say whether a component's overrides cover the components inside it: the single
38/// one files before schema 1.9 write, then one per quantity. Which of them wins is their order in
39/// the file ([`Values::overrides`]).
40pub(crate) const OVERRIDE_FLAGS: [&str; 4] = [
41    "overridesubcomponents",
42    "overridesubcomponentsmass",
43    "overridesubcomponentscg",
44    "overridesubcomponentscd",
45];
46
47// `angleoffset`/`radialdirection` and `radiusoffset`/`radialposition` are deliberately **not**
48// here. They look like the two pairs above, and they are not: the newer name of each carries a
49// `method` attribute (the frame the number is measured in) that the older name never carries.
50// `cargo xtask ork` measures it: of the 26 elements with both `angleoffset` and `radialdirection`
51// the texts agree every time and the frames differ every time, and `radiusoffset` (106 elements)
52// and `radialposition` (542) are never written together at all. Reading one as the other would
53// silently move a component, so settling what the older name's frame is belongs to the milestone
54// that places components (M3.1b2), with a source, not to a constant here. See the `.ork` page:
55// <https://nrdptel.github.io/hpr-sim/format/ork.html>.
56
57/// A number a `.ork` writes, which OpenRocket may be working out for itself.
58#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
59#[serde(rename_all = "snake_case", tag = "kind")]
60#[non_exhaustive]
61#[serde(deny_unknown_fields)]
62pub enum Dimension {
63    /// A number the designer typed.
64    Stated {
65        /// The number, in whatever unit the tag is written in.
66        value: f64,
67    },
68    /// A number OpenRocket works out from the neighbouring components.
69    Automatic {
70        /// What it last worked out, when the file says (`auto 0.0125`). A bare `auto` gives
71        /// `None`. Either way this is a cached answer, not an input: a reader that resolves the
72        /// dimension itself should prefer its own.
73        cached: Option<f64>,
74    },
75}
76
77impl Dimension {
78    /// The number, whether it was typed or cached. `None` for a bare `auto`.
79    pub fn value(self) -> Option<f64> {
80        match self {
81            Self::Stated { value } => Some(value),
82            Self::Automatic { cached } => cached,
83        }
84    }
85
86    /// Whether OpenRocket works this dimension out for itself.
87    pub fn is_automatic(self) -> bool {
88        matches!(self, Self::Automatic { .. })
89    }
90
91    /// Reads a dimension from an element's text, or `None` when the text is neither a finite
92    /// number nor `auto`.
93    ///
94    /// Only a tag that *holds* a dimension should be read this way. `<ignitionevent>automatic
95    /// </ignitionevent>` is a word, not a number, and gives `None` rather than an automatic
96    /// dimension.
97    pub fn parse(text: &str) -> Option<Self> {
98        let text = text.trim();
99        if let Some(rest) = text.strip_prefix("auto") {
100            if rest.is_empty() {
101                return Some(Self::Automatic { cached: None });
102            }
103            // `auto` is a word: `auto 0.025` is a cached dimension, `auto-1` and `automatic` are
104            // not this tag's business.
105            let cached = rest.strip_prefix(char::is_whitespace)?.trim();
106            return finite(cached).map(|cached| Self::Automatic {
107                cached: Some(cached),
108            });
109        }
110        finite(text).map(|value| Self::Stated { value })
111    }
112}
113
114/// Reads a `.ork` element's child values, collecting a warning for anything it cannot.
115///
116/// Borrowed rather than owned so that walking a tree costs nothing: make one per element.
117#[derive(Debug)]
118pub struct Values<'a> {
119    element: &'a Element,
120    at: &'a str,
121    warnings: &'a mut Vec<Warning>,
122}
123
124impl<'a> Values<'a> {
125    /// Reads the children of `element`, reporting anything odd as happening at `at`.
126    pub fn new(element: &'a Element, at: &'a str, warnings: &'a mut Vec<Warning>) -> Self {
127        Self {
128            element,
129            at,
130            warnings,
131        }
132    }
133
134    /// The first child called one of `names`, newest name first.
135    ///
136    /// When more than one is present they are checked against each other, on the text **and** on
137    /// the `method`/`type` attribute that says what the text is measured from: in the reference
138    /// corpus OpenRocket agrees with itself on both, every time. A disagreement is therefore worth
139    /// a warning, and the first name still wins. The name that lost says something the design
140    /// does not hold, so it is kept as written, in `x-openrocket`.
141    pub fn element(&mut self, names: &[&str]) -> Option<&'a Element> {
142        let mut found: Option<&'a Element> = None;
143        for name in names {
144            super::reads::note(self.element, name);
145            let Some(child) = self.element.child(name) else {
146                continue;
147            };
148            let Some(first) = found else {
149                found = Some(child);
150                continue;
151            };
152            let (winner, loser) = (first.name.clone(), child.name.clone());
153            let disagree =
154                first.text().trim() != child.text().trim() || frame(first) != frame(child);
155            if disagree {
156                super::reads::forget(self.element, &loser);
157            }
158            if first.text().trim() != child.text().trim() {
159                self.warn(
160                    WarningKind::Dropped,
161                    format!(
162                        "`{winner}` says `{}` and `{loser}` says `{}`; they are two names for one \
163                         value, so `{winner}` was taken",
164                        first.text().trim(),
165                        child.text().trim()
166                    ),
167                );
168            } else if frame(first) != frame(child) {
169                self.warn(
170                    WarningKind::Dropped,
171                    format!(
172                        "`{winner}` and `{loser}` both say `{}` but measure it from {} and {}; \
173                         `{winner}` was taken",
174                        first.text().trim(),
175                        named(frame(first)),
176                        named(frame(child))
177                    ),
178                );
179            }
180        }
181        found
182    }
183
184    /// The text of the first child called one of `names`, trimmed. An empty tag gives `""`.
185    pub fn word(&mut self, names: &[&str]) -> Option<String> {
186        self.element(names)
187            .map(|child| child.text().trim().to_owned())
188    }
189
190    /// A number. A tag whose text is not a finite number is ignored, with a warning.
191    pub fn number(&mut self, names: &[&str]) -> Option<f64> {
192        let child = self.element(names)?;
193        let text = child.text();
194        match finite(text.trim()) {
195            Some(value) => Some(value),
196            None => {
197                let name = child.name.clone();
198                self.warn(
199                    WarningKind::Dropped,
200                    format!(
201                        "`{name}` says `{}`, which is not a number; it was ignored",
202                        text.trim()
203                    ),
204                );
205                self.forget(names);
206                None
207            }
208        }
209    }
210
211    /// A count. A negative or fractional one is ignored, with a warning.
212    pub fn count(&mut self, names: &[&str]) -> Option<u32> {
213        let value = self.number(names)?;
214        let rounded = value.round();
215        if rounded < 0.0 || rounded > f64::from(u32::MAX) || (value - rounded).abs() > 1e-9 {
216            self.warn(
217                WarningKind::Dropped,
218                format!("a count of `{value}` is not a whole number of things; it was ignored"),
219            );
220            self.forget(names);
221            return None;
222        }
223        // The bounds above are what make this cast exact.
224        #[expect(
225            clippy::cast_possible_truncation,
226            clippy::cast_sign_loss,
227            reason = "checked to be a whole number within u32 on the line above"
228        )]
229        Some(rounded as u32)
230    }
231
232    /// A `true`/`false` flag. Anything else is ignored, with a warning.
233    pub fn flag(&mut self, names: &[&str]) -> Option<bool> {
234        let child = self.element(names)?;
235        match child.text().trim() {
236            "true" => Some(true),
237            "false" => Some(false),
238            other => {
239                let name = child.name.clone();
240                let other = other.to_owned();
241                self.warn(
242                    WarningKind::Dropped,
243                    format!(
244                        "`{name}` says `{other}`, which is neither true nor false; it was ignored"
245                    ),
246                );
247                self.forget(names);
248                None
249            }
250        }
251    }
252
253    /// A dimension, which may be `auto` with or without the number OpenRocket last worked out.
254    pub fn dimension(&mut self, names: &[&str]) -> Option<Dimension> {
255        let child = self.element(names)?;
256        let text = child.text();
257        match Dimension::parse(&text) {
258            Some(dimension) => Some(dimension),
259            None => {
260                let name = child.name.clone();
261                self.warn(
262                    WarningKind::Dropped,
263                    format!(
264                        "`{name}` says `{}`, which is neither a number nor `auto`; it was ignored",
265                        text.trim()
266                    ),
267                );
268                self.forget(names);
269                None
270            }
271        }
272    }
273
274    /// Records a warning about the element being read, for a caller that decided something this
275    /// reader could not.
276    pub fn warn_at(&mut self, kind: WarningKind, message: impl Into<String>) {
277        self.warn(kind, message.into());
278    }
279
280    /// Records that what the children called any of `names` say was dropped or simplified, not
281    /// read into the design, so that each is kept as written ([`super::reads::forget`]) and an
282    /// export writes it back.
283    pub(crate) fn forget(&mut self, names: &[&str]) {
284        for name in names {
285            super::reads::forget(self.element, name);
286        }
287    }
288
289    /// The mass, center of gravity and drag a component's own figures are replaced by, and
290    /// whether each covers the components inside this one.
291    ///
292    /// Until schema 1.9 a file said that last once, with `overridesubcomponents`, for all three
293    /// quantities. OpenRocket 24.12 reads that flag, in schema 1.4, 1.8 and 1.10 files alike, as
294    /// setting all three, and where a component writes it beside a per-quantity flag, the tag
295    /// written later wins, quantity by quantity: measured on probe designs
296    /// ([M2.2e6][m2-2e6], [ADR-095][adr-095]), whose answers `hpr_validate::openrocket` holds hpr
297    /// to. This reads it the same way. A flag tag written twice on one component is read at its
298    /// first copy only, and warned of, since which copy OpenRocket takes is not measured. What the
299    /// design does not hold (a flag read at one copy of two, or the single older flag beside a
300    /// drag override, which the design says per quantity) is kept as written, the flags all
301    /// together, so that an export writes back what the file said, in its order.
302    ///
303    /// [m2-2e6]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m2-2e6
304    /// [adr-095]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-095-the-single-pre-19-override-flag-read-as-openrocket-reads-it-2026-09-27
305    pub fn overrides(&mut self) -> Overrides {
306        let mass_kg = self.number(&["overridemass"]);
307        let cg_m = self.number(&["overridecg"]);
308        let cd = self.number(&["overridecd"]);
309        let all = self.placed_flag("overridesubcomponents");
310        let [mass, cg, drag] = [
311            "overridesubcomponentsmass",
312            "overridesubcomponentscg",
313            "overridesubcomponentscd",
314        ]
315        .map(|name| match (self.placed_flag(name), all) {
316            (Some((own, own_at)), Some((all, all_at))) => {
317                Some(if all_at > own_at { all } else { own })
318            }
319            (Some((own, _)), None) => Some(own),
320            (None, Some((all, _))) => Some(all),
321            (None, None) => None,
322        });
323        // What the design does not hold is kept as written. The flags are kept together when any
324        // one is, since which of them wins is their order in the file, and a kept tag goes back
325        // after the ones an export writes.
326        let dropped_flag = OVERRIDE_FLAGS.iter().any(|name| {
327            let mut copies = self.element.children_named(name);
328            let first = copies.next();
329            copies.next().is_some()
330                || first.is_some_and(|flag| !matches!(flag.text().trim(), "true" | "false"))
331        });
332        // The single older flag covers drag too: beside a drag override, it is kept as written.
333        let drag_stated = ["overridecd", "overridesubcomponentscd"]
334            .iter()
335            .any(|name| self.element.child(name).is_some());
336        if dropped_flag || (drag_stated && all.is_some()) {
337            self.forget(&OVERRIDE_FLAGS);
338        }
339        Overrides {
340            mass_kg,
341            cg_m,
342            cd,
343            subcomponents_mass: mass,
344            subcomponents_cg: cg,
345            subcomponents_cd: drag,
346        }
347    }
348
349    /// The flag called `name`, and where it stands among the element's children in document
350    /// order. A tag written more than once is read at its first copy, with a warning.
351    fn placed_flag(&mut self, name: &str) -> Option<(bool, usize)> {
352        let copies = self.element.children_named(name).count();
353        if copies > 1 {
354            self.warn(
355                WarningKind::Dropped,
356                format!("`{name}` is written {copies} times; only the first is read"),
357            );
358        }
359        let value = self.flag(&[name])?;
360        let place = self
361            .element
362            .elements()
363            .position(|child| child.name == name)?;
364        Some((value, place))
365    }
366
367    fn warn(&mut self, kind: WarningKind, message: impl Into<String>) {
368        self.warnings.push(Warning::new(self.at, kind, message));
369    }
370}
371
372/// What a component says its own mass, center of gravity and drag coefficient are, instead of
373/// what its geometry and material would give.
374///
375/// The three values and the three flags are independent, which is [Loft lesson L63: Loft read
376/// neither `overridecd` nor `overridesubcomponentscg`, so a part set to a drag coefficient of zero
377/// was still charged full drag][lessons]. A stated `0.0` here is an override to zero, not a
378/// missing one.
379///
380/// [lessons]: https://github.com/nrdptel/hpr-sim/blob/main/docs/research/loft-lessons.md
381#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
382#[non_exhaustive]
383pub struct Overrides {
384    /// The mass the component is declared to have, in kilograms.
385    pub mass_kg: Option<f64>,
386    /// Where its center of gravity is declared to be, in meters from its fore end.
387    pub cg_m: Option<f64>,
388    /// The drag coefficient it is declared to have.
389    pub cd: Option<f64>,
390    /// Whether the mass override covers the components inside this one too.
391    pub subcomponents_mass: Option<bool>,
392    /// Whether the center-of-gravity override covers them.
393    pub subcomponents_cg: Option<bool>,
394    /// Whether the drag override covers them.
395    pub subcomponents_cd: Option<bool>,
396}
397
398/// What a placement tag says its number is measured from: `method` on the newer name of a pair,
399/// `type` on the older.
400fn frame(element: &Element) -> Option<&str> {
401    element
402        .attribute("method")
403        .or_else(|| element.attribute("type"))
404}
405
406fn named(frame: Option<&str>) -> String {
407    frame.map_or_else(|| "nowhere stated".to_owned(), |frame| format!("`{frame}`"))
408}
409
410/// Rust also parses `inf` and `NaN`, which no design file means.
411fn finite(text: &str) -> Option<f64> {
412    text.parse::<f64>().ok().filter(|value| value.is_finite())
413}