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}