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}