Skip to main content

hpr_io/ork/
extensions.rs

1//! What a `.ork` design holds that hpr's design does not model, kept whole for an export to put
2//! back: the `x-openrocket` extension.
3//!
4//! **What is kept.** Four kinds of thing, each with the path it was found at:
5//!
6//! - **Parts** hpr does not read: every child of a `<subcomponents>` that the walk left out: a pod
7//!   set or a parallel stage ([Loft lesson L66][l66]: Loft dropped them), a part hpr cannot give a
8//!   shape, or a tag it has never seen. A design with any is *reduced*: its rocket is not the whole
9//!   of what the file describes ([`super::Design::is_reduced`]).
10//! - **Sections** of the document hpr does not read: every child of `<openrocket>` besides
11//!   `<rocket>` and `<simulations>` (such as `<photostudio>` or `<docprefs>`), and every child of a
12//!   stored `<simulation>` besides its name, simulator, calculator, conditions and flight data (such
13//!   as a simulation `<extension>`).
14//!
15//! - **Tags** no reader asks for in an element hpr does read (the rocket, a stage, a part, a
16//!   stored simulation, and any tag inside those a reader did ask for), such as a part's
17//!   `<appearance>`.
18//! - **Attributes** no reader asks for on an element hpr does read, such as a material's `group`.
19//!
20//! The readers record every tag and attribute they ask for while [`super::design`] reads, so one is
21//! kept when nothing asked for it. One a reader asked for and then dropped or simplified (a rail
22//! button's screw height, a drag override, a ring's count above one, a word with no reading) is
23//! kept too, since the design does not hold what it says: an export writes it back in place of
24//! what the design would, and reading that export warns of it again.
25//!
26//! **What is not.** The text of a second copy of a tag a reader takes once by name; its attributes
27//! and unread children are kept. Everything is still in the document itself, which
28//! [`super::OrkFile`] keeps whole ([ADR-051][adr-051]); [`super::export`] writes a design back out
29//! from the design and these.
30//!
31//! **The path.** `openrocket/rocket/stage[0]/bodytube[1]/podset[0]` counts each part's step among
32//! all its parent's `<subcomponents>` children, the way a warning's path does, since the order of
33//! parts is where they stack. A section's step, and a tag's, marked `@` at any depth, counts only
34//! among the parent's child elements of its own name: `openrocket/rocket/stage[0]/nosecone[0]/
35//! @appearance[0]` is the nose cone's first `<appearance>`, wherever it stood among the other
36//! tags. OpenRocket does not read meaning into the order of tags, and counting this way lets an
37//! export put each one back without knowing that order ([ADR-109][adr-109]). A tag that belongs
38//! to one configuration, by its `configid` attribute, names it in its step and counts only among
39//! the tags of its name for that configuration: `…/parachute[0]/@deploymentconfiguration(b)[0]`
40//! is the parachute's `<deploymentconfiguration>` for configuration `b`, wherever the file put it
41//! among the other configurations', since an export writes them in the design's order of
42//! configurations. A `configid` that is empty or holds a `/`, `(`, `)`, `[` or `]` is not named,
43//! and its tag counts among the others of its name that name none. An attribute keeps the path
44//! of the element it was on. [`element_at`] follows a path back.
45//!
46//! [l66]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l66
47//! [adr-051]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-051-m31-split-and-the-ork-document-kept-whole-rather-than-interpreted-2026-09-20
48//! [adr-109]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-109-m32-split-and-a-ork-written-from-the-design-2026-09-29
49
50use std::collections::BTreeSet;
51
52use serde::{Deserialize, Serialize};
53
54use super::component::subcomponents;
55use super::document::{Document, Element};
56use super::reads::{self, Reads};
57
58/// The extensions a `.ork` design carries, by namespace.
59#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
60#[serde(deny_unknown_fields)]
61#[non_exhaustive]
62pub struct Extensions {
63    /// What the design holds that hpr does not model.
64    #[serde(rename = "x-openrocket", default)]
65    pub x_openrocket: OpenRocketExtension,
66}
67
68/// The `x-openrocket` extension: the parts and sections of a `.ork` that hpr does not read, each
69/// kept whole where it was.
70#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
71#[non_exhaustive]
72#[serde(deny_unknown_fields)]
73pub struct OpenRocketExtension {
74    /// The parts hpr does not read, in file order.
75    #[serde(default)]
76    pub parts: Vec<Kept>,
77    /// The sections of the document hpr does not read, in file order.
78    #[serde(default)]
79    pub sections: Vec<Kept>,
80    /// The tags hpr does not read in an element it does read (a part, a stage, the rocket, a
81    /// stored simulation, or a tag inside any of those that a reader asked for), such as a part's
82    /// `<appearance>`; and those a reader asked for and dropped or simplified, such as a ring's
83    /// `<instancecount>` past one, which the design does not hold.
84    #[serde(default)]
85    pub tags: Vec<Kept>,
86    /// The attributes hpr does not read on an element it does read, such as a material's
87    /// `group`, and those whose value a reader dropped, such as a material's declared `type` where
88    /// the part needs another.
89    #[serde(default)]
90    pub attributes: Vec<KeptAttribute>,
91}
92
93/// An attribute kept, and the element it was on.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
95#[non_exhaustive]
96#[serde(deny_unknown_fields)]
97pub struct KeptAttribute {
98    /// The path of the element it was on; see [`element_at`].
99    pub at: String,
100    /// Its name.
101    pub name: String,
102    /// Its value, as written.
103    pub value: String,
104}
105
106/// An element kept whole, and where it was.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
108#[non_exhaustive]
109#[serde(deny_unknown_fields)]
110pub struct Kept {
111    /// Its path in the document; see [`element_at`].
112    pub at: String,
113    /// The element, with everything inside it.
114    pub element: Element,
115}
116
117/// Everything in `document` that hpr does not read, given the paths of the stages and components
118/// the walk read and every tag and attribute the readers asked for.
119pub(super) fn read(
120    document: &Document,
121    read: &BTreeSet<String>,
122    asked: &Reads,
123) -> OpenRocketExtension {
124    let mut kept = OpenRocketExtension::default();
125    let root = &document.root;
126    // hpr reads the first `<rocket>` and the first `<simulations>`; a second is kept whole.
127    let (mut rocket_seen, mut simulations_seen) = (false, false);
128    let mut sections = Counter::default();
129    for child in root.elements() {
130        let index = sections.next(&child.name);
131        match child.name.as_str() {
132            "rocket" if !std::mem::replace(&mut rocket_seen, true) => {
133                unasked(child, "openrocket/rocket", asked, &mut kept);
134                for (index, part) in subcomponents(child).enumerate() {
135                    let at = format!("openrocket/rocket/{}[{index}]", part.name);
136                    parts(part, &at, read, asked, &mut kept);
137                }
138            }
139            "simulations" if !std::mem::replace(&mut simulations_seen, true) => {
140                // Anything beside the `<simulation>`s is kept, counted among its own name.
141                let mut seen = Counter::default();
142                for other in child.elements().filter(|e| e.name != "simulation") {
143                    let index = seen.next(&other.name);
144                    kept.sections.push(Kept {
145                        at: format!("openrocket/simulations/{}[{index}]", other.name),
146                        element: other.clone(),
147                    });
148                }
149                for (index, simulation) in child.children_named("simulation").enumerate() {
150                    let at = format!("openrocket/simulations/simulation[{index}]");
151                    keep_attributes(simulation, &at, asked, &mut kept);
152                    let mut seen = Counter::default();
153                    for inside in simulation.elements() {
154                        let index = seen.next(&inside.name);
155                        let path = format!("{at}/{}[{index}]", inside.name);
156                        if reads::asked(asked, simulation, &inside.name) {
157                            unasked(inside, &path, asked, &mut kept);
158                        } else {
159                            kept.sections.push(Kept {
160                                at: path,
161                                element: inside.clone(),
162                            });
163                        }
164                    }
165                }
166            }
167            _ => kept.sections.push(Kept {
168                at: format!("openrocket/{}[{index}]", child.name),
169                element: child.clone(),
170            }),
171        }
172    }
173    kept
174}
175
176/// Keeps the attributes of `element` no reader asked for, and each child tag no reader asked for,
177/// looking inside the ones a reader did. The parts inside it are [`parts`]'s.
178fn unasked(element: &Element, at: &str, asked: &Reads, kept: &mut OpenRocketExtension) {
179    keep_attributes(element, at, asked, kept);
180    let mut seen = Counter::default();
181    for tag in element.elements() {
182        let key = tag_key(tag);
183        let index = seen.next(&key);
184        if tag.name == "subcomponents" {
185            continue;
186        }
187        let path = format!("{at}/@{key}[{index}]");
188        if reads::asked(asked, element, &tag.name) {
189            unasked(tag, &path, asked, kept);
190        } else {
191            kept.tags.push(Kept {
192                at: path,
193                element: tag.clone(),
194            });
195        }
196    }
197}
198
199/// Keeps the attributes of `element` no reader asked for.
200fn keep_attributes(element: &Element, at: &str, asked: &Reads, kept: &mut OpenRocketExtension) {
201    for (name, value) in &element.attributes {
202        if !reads::asked_attribute(asked, element, name) {
203            kept.attributes.push(KeptAttribute {
204                at: at.to_owned(),
205                name: name.clone(),
206                value: value.clone(),
207            });
208        }
209    }
210}
211
212/// Keeps `element` whole if the walk did not read it, or looks inside it if it did: at what no
213/// reader asked for in it, and at the parts inside it.
214fn parts(
215    element: &Element,
216    at: &str,
217    read: &BTreeSet<String>,
218    asked: &Reads,
219    kept: &mut OpenRocketExtension,
220) {
221    if !read.contains(at) {
222        kept.parts.push(Kept {
223            at: at.to_owned(),
224            element: element.clone(),
225        });
226        return;
227    }
228    unasked(element, at, asked, kept);
229    for (index, child) in subcomponents(element).enumerate() {
230        parts(
231            child,
232            &format!("{at}/{}[{index}]", child.name),
233            read,
234            asked,
235            kept,
236        );
237    }
238}
239
240/// The element of `document` at `at`, a path as [`Kept::at`] writes it, or `None` when the path
241/// does not lead to an element of the name it gives.
242pub fn element_at<'a>(document: &'a Document, at: &str) -> Option<&'a Element> {
243    let mut steps = at.split('/');
244    if steps.next() != Some("openrocket") {
245        return None;
246    }
247    let root = &document.root;
248    let step = |text: &str| -> Option<(String, usize)> {
249        let (name, rest) = text.split_once('[')?;
250        let index = rest.strip_suffix(']')?.parse().ok()?;
251        Some((name.to_owned(), index))
252    };
253    // A tag step, `@name[k]` or `@name(configid)[k]`: the k-th child element with that key.
254    let tag = |here: &'a Element, text: &str| -> Option<&'a Element> {
255        let (key, index) = step(text.strip_prefix('@')?)?;
256        here.elements()
257            .filter(|child| tag_key(child) == key)
258            .nth(index)
259    };
260    match steps.next()? {
261        "rocket" => {
262            let mut here = root.child("rocket")?;
263            let mut in_tags = false;
264            for text in steps {
265                // Only tags follow a tag.
266                if text.starts_with('@') {
267                    in_tags = true;
268                    here = tag(here, text)?;
269                } else if in_tags {
270                    return None;
271                } else {
272                    let (name, index) = step(text)?;
273                    here = subcomponents(here)
274                        .nth(index)
275                        .filter(|child| child.name == name)?;
276                }
277            }
278            Some(here)
279        }
280        "simulations" => {
281            let (name, index) = step(steps.next()?)?;
282            let simulation = root
283                .child("simulations")?
284                .children_named(&name)
285                .nth(index)?;
286            let Some(text) = steps.next() else {
287                return Some(simulation);
288            };
289            let (name, index) = step(text)?;
290            let mut here = simulation.children_named(&name).nth(index)?;
291            for text in steps {
292                here = tag(here, text)?;
293            }
294            Some(here)
295        }
296        text => {
297            let (name, index) = step(text)?;
298            let section = root.children_named(&name).nth(index)?;
299            steps.next().is_none().then_some(section)
300        }
301    }
302}
303
304/// The key a tag's step names it by, which it counts among: its name, and, when it belongs to one
305/// configuration, that configuration's id in brackets, `deploymentconfiguration(b)`. The id is
306/// left out when it is empty or holds a character a path gives meaning to, `/`, `(`, `)`, `[` or
307/// `]`, so that the path still reads back.
308///
309/// The attribute is looked up directly rather than through [`Element::attribute`], so a path is
310/// never a read that [`reads`] records; the extension is read after the recording has stopped in
311/// any case ([`super::design`]).
312pub(crate) fn tag_key(element: &Element) -> String {
313    match element
314        .attributes
315        .iter()
316        .find(|(name, _)| name == "configid")
317    {
318        // The readers trim a configuration's id, and the writers write it trimmed.
319        Some((_, configid)) => configuration_key(&element.name, configid.trim()),
320        None => element.name.clone(),
321    }
322}
323
324/// The key of the tag step for a `<name>` of configuration `configid`, as [`tag_key`] gives it
325/// for such an element.
326pub(crate) fn configuration_key(name: &str, configid: &str) -> String {
327    if configid.is_empty() || configid.contains(['/', '(', ')', '[', ']']) {
328        name.to_owned()
329    } else {
330        format!("{name}({configid})")
331    }
332}
333
334/// Counts the child elements met so far by name, for a step that counts among its own name.
335#[derive(Default)]
336pub(super) struct Counter(std::collections::BTreeMap<String, usize>);
337
338impl Counter {
339    /// How many elements called `name` came before this one; counts this one.
340    pub(super) fn next(&mut self, name: &str) -> usize {
341        let count = self.0.entry(name.to_owned()).or_default();
342        let index = *count;
343        *count += 1;
344        index
345    }
346}