1use hpr_design::fins::{FinCrossSection, FinFillet, FinPlanform, FinSet, FinTab, TubeFinSet};
32use hpr_design::parts::{
33 CenteringRing, InnerTube, LaunchLug, MassComponent, Packing, Parachute, PodSet, RailButton,
34 ShockCord, Streamer,
35};
36use hpr_design::tree::{AutoDimension, Component, Overrides, ParallelStage, Part, Position, Stage};
37use hpr_design::{Finish, MotorMount, Wall};
38
39use super::component::{
40 BODY_TAGS, Ids, UNNAMED_RAIL_BUTTON, body, material, material_or, overrides, stated_radius,
41 subcomponents,
42};
43use super::document::Element;
44use super::motors;
45use super::recovery;
46use super::value::{AXIAL_OFFSET, INSTANCE_COUNT, OVERRIDE_FLAGS, Values};
47use super::warning::{Warning, WarningKind};
48
49const TUBES: [&str; 3] = ["innertube", "tubecoupler", "engineblock"];
51
52pub const ATTACHED_TAGS: &[&str] = &[
58 "innertube",
59 "tubecoupler",
60 "engineblock",
61 "centeringring",
62 "bulkhead",
63 "trapezoidfinset",
64 "ellipticalfinset",
65 "freeformfinset",
66 "tubefinset",
67 "launchlug",
68 "railbutton",
69 "masscomponent",
70 "parachute",
71 "streamer",
72 "shockcord",
73 "podset",
74 "parallelstage",
75];
76
77const MOST_INSTANCES: u32 = 64;
85
86fn nested(element: &Element) -> usize {
89 subcomponents(element).map(|child| 1 + nested(child)).sum()
90}
91
92fn and_what_was_inside(element: &Element) -> String {
94 match nested(element) {
95 0 => String::new(),
96 1 => ", and the one part inside it".to_owned(),
97 many => format!(", and the {many} parts inside it"),
98 }
99}
100
101fn spoken(part: &Part) -> String {
103 part.kind_name().replace('_', " ")
104}
105
106pub(super) fn children(
114 element: &Element,
115 parent: &Part,
116 parent_auto: &[AutoDimension],
117 at: &str,
118 ids: &mut Ids,
119 skipped: &mut Vec<String>,
120 warnings: &mut Vec<Warning>,
121) -> Vec<Component> {
122 let mut components = Vec::new();
123 for (index, child) in subcomponents(element).enumerate() {
124 let at = format!("{at}/{}[{index}]", child.name);
125 let read = if child.name == "podset" {
126 pod_set(child, parent, parent_auto, &at, ids, skipped, warnings)
127 } else if child.name == "parallelstage" {
128 if let Some(stage) =
130 parallel_stage(child, parent, parent_auto, &at, ids, skipped, warnings)
131 {
132 ids.parallel.push(stage);
133 }
134 continue;
135 } else {
136 one(child, parent, parent_auto, &at, ids, skipped, warnings)
137 };
138 match read {
139 Some(component) => components.push(component),
140 None => {
143 if !ATTACHED_TAGS.contains(&child.name.as_str()) {
144 skipped.push(child.name.clone());
145 }
146 }
147 }
148 }
149 components
150}
151
152fn one(
154 element: &Element,
155 parent: &Part,
156 parent_auto: &[AutoDimension],
157 at: &str,
158 ids: &mut Ids,
159 skipped: &mut Vec<String>,
160 warnings: &mut Vec<Warning>,
161) -> Option<Component> {
162 let mut auto = Vec::new();
163 let mut values = Values::new(element, at, warnings);
164 let part = match element.name.as_str() {
165 tag if TUBES.contains(&tag) => inner_tube(tag, &mut values, &mut auto),
166 "centeringring" => ring(&mut values, &mut auto, true),
167 "bulkhead" => ring(&mut values, &mut auto, false),
168 "trapezoidfinset" | "ellipticalfinset" | "freeformfinset" => fin_set(element, &mut values),
169 "tubefinset" => tube_fins(&mut values, &mut auto),
170 "launchlug" => launch_lug(&mut values),
171 "railbutton" => rail_button(&mut values),
172 "masscomponent" => mass_component(&mut values, &mut auto),
173 "parachute" => parachute(&mut values, &mut auto),
174 "streamer" => streamer(&mut values, &mut auto),
175 "shockcord" => shock_cord(&mut values, &mut auto),
176 _ => return None,
179 }?;
180
181 let on_a_surface = matches!(part, Part::FinSet(_))
186 && matches!(parent, Part::NoseCone(_) | Part::Transition(_));
187 if part.is_external() && !matches!(parent, Part::BodyTube(_)) && !on_a_surface {
188 values.warn_at(
189 WarningKind::Skipped,
190 format!(
191 "a {} sits on a {}, and hpr attaches one only to a body tube; it was left out{}",
192 spoken(&part),
193 spoken(parent),
194 and_what_was_inside(element)
195 ),
196 );
197 return None;
198 }
199 let hollow = |wall: &Wall| matches!(wall, Wall::Shell { .. });
209 let bore_along = match parent {
210 Part::BodyTube(_) | Part::InnerTube(_) => true,
211 Part::NoseCone(nose) => hollow(&nose.wall),
212 Part::Transition(transition) => hollow(&transition.wall),
213 _ => false,
214 };
215 let needs_a_bore = auto.iter().any(|dimension| match dimension {
218 AutoDimension::OuterRadius => !bore_along,
219 AutoDimension::PackedRadius => !matches!(parent, Part::BodyTube(_) | Part::InnerTube(_)),
220 _ => false,
221 });
222 if needs_a_bore {
223 values.warn_at(
224 WarningKind::Skipped,
225 format!(
226 "the {} takes an automatic radius from its parent's bore, and a {} has none; it \
227 was left out{}",
228 spoken(&part),
229 spoken(parent),
230 and_what_was_inside(element)
231 ),
232 );
233 return None;
234 }
235
236 let [x, y] = parent.axis_offset_m();
240 if !part.is_external() && (x != 0.0 || y != 0.0) {
241 values.warn_at(
242 WarningKind::Unusual,
243 format!(
244 "the {} sits inside a {} {} m off the body's axis: OpenRocket places it from that \
245 tube's axis, and hpr from the body's axis (issue #181){}",
246 spoken(&part),
247 spoken(parent),
248 x.hypot(y),
249 match nested(element) {
250 0 => String::new(),
251 1 => "; so is the one part inside it".to_owned(),
252 many => format!("; so are the {many} parts inside it"),
253 }
254 ),
255 );
256 }
257
258 let name = values.word(&["name"]).unwrap_or_default();
259 let position = match &part {
260 Part::RailButton(button) => centered_on_its_position(position(&mut values), button),
261 _ => position(&mut values),
262 };
263 let part = match follow_the_body(part, parent, parent_auto, position) {
264 Ok(part) => part,
265 Err(why) => {
266 values.warn_at(WarningKind::Skipped, why);
267 return None;
268 }
269 };
270 let finish = finish(&mut values);
271 let (overrides, include_children, drag_override) = overrides(&mut values);
272 radial_offset_on_the_surface(&mut values, &part);
273 let children = if matches!(part, Part::InnerTube(_)) {
277 children(element, &part, &auto, at, ids, skipped, warnings)
278 } else {
279 if subcomponents(element).next().is_some() {
280 let (kind, inside) = (spoken(&part), and_what_was_inside(element));
281 values.warn_at(
282 WarningKind::Skipped,
283 format!("a {kind} holds no parts in hpr; it was read without{inside}"),
284 );
285 }
286 Vec::new()
287 };
288 let mount = match part {
289 Part::InnerTube(_) => motors::mount(element, at, warnings),
290 _ => None,
291 };
292 let device = match part {
293 Part::Parachute(_) | Part::Streamer(_) => Some(recovery::device(element, at, warnings)),
294 _ => None,
295 };
296 let id = ids.take(
297 &mut Values::new(element, at, warnings),
298 &element.name.clone(),
299 );
300 let motor_mount = mount.map(|mount| {
301 let spec = MotorMount {
302 overhang_m: mount.overhang_m,
303 };
304 ids.mounts.push((id.clone(), mount));
305 spec
306 });
307 if let Some(device) = device {
308 ids.devices.push((id.clone(), device));
309 }
310 ids.read.insert(at.to_owned());
311 Some(Component {
312 id,
313 name,
314 part,
315 position: Some(position),
316 auto,
317 motor_mount,
318 finish,
319 overrides,
320 overrides_include_children: include_children,
321 drag_override,
322 children,
323 })
324}
325
326fn pod_set(
352 element: &Element,
353 parent: &Part,
354 parent_auto: &[AutoDimension],
355 at: &str,
356 ids: &mut Ids,
357 skipped: &mut Vec<String>,
358 warnings: &mut Vec<Warning>,
359) -> Option<Component> {
360 let mut values = Values::new(element, at, warnings);
361 let Part::BodyTube(tube) = parent else {
362 values.warn_at(
363 WarningKind::Skipped,
364 format!(
365 "a pod set hangs from a {}, and hpr hangs one only from a body tube; it was left \
366 out{}",
367 spoken(parent),
368 and_what_was_inside(element)
369 ),
370 );
371 return None;
372 };
373 if at.matches("/podset[").count() + at.matches("/parallelstage[").count() > 1 {
376 values.warn_at(
377 WarningKind::Skipped,
378 format!(
379 "a pod set inside a pod, which hpr does not nest; it was left out{}",
380 and_what_was_inside(element)
381 ),
382 );
383 return None;
384 }
385 let count = instances(&mut values, "pod set")?;
386 let name = values.word(&["name"]).unwrap_or_default();
387 let position = position(&mut values);
388 let (mut overrides, include_children, mut drag_override) = overrides(&mut values);
389 let angle_rad = roll_angle(&mut values);
390 let method = values
391 .element(&["radiusoffset"])
392 .map(|offset| offset.attribute("method").unwrap_or("relative").to_owned());
393 let number = values.number(&["radiusoffset"]);
394 let id = ids.take(&mut Values::new(element, at, warnings), &element.name);
395
396 let mut pods = Vec::new();
397 for (index, child) in subcomponents(element).enumerate() {
398 let at = format!("{at}/{}[{index}]", child.name);
399 if BODY_TAGS.contains(&child.name.as_str()) {
400 pods.push(body(child, &at, ids, skipped, warnings).0);
401 } else {
402 let tag = child.name.clone();
403 Values::new(child, &at, warnings).warn_at(
404 WarningKind::Skipped,
405 format!(
406 "a `{tag}` directly inside a pod set, where hpr's pod is a stack of nose \
407 cones, body tubes and transitions; it was left out{}",
408 and_what_was_inside(child)
409 ),
410 );
411 }
412 }
413 let mut values = Values::new(element, at, warnings);
414 if pods.iter().any(|pod| {
419 let length_m = pod.part.length_m();
420 length_m < 0.0 || (length_m == 0.0 && !matches!(pod.part, Part::BodyTube(_)))
421 }) {
422 values.warn_at(
423 WarningKind::Skipped,
424 format!(
425 "a pod with a nose cone or transition of no length, or a part of negative length, \
426 which hpr cannot lay out; the pod set was left out{}",
427 and_what_was_inside(element)
428 ),
429 );
430 return None;
431 }
432 let flat = |pod: &Component| pod.part.length_m() == 0.0;
434 if pods
435 .iter()
436 .any(|pod| flat(pod) && pod.children.iter().any(|c| !c.part.is_external()))
437 {
438 values.warn_at(
439 WarningKind::Skipped,
440 format!(
441 "a pod's tube of no length with a part inside it, where it has no room; the pod set \
442 was left out{}",
443 and_what_was_inside(element)
444 ),
445 );
446 return None;
447 }
448 let mut widest: Option<f64> = None;
450 for pod in &pods {
451 let radii: &[(f64, AutoDimension)] = match &pod.part {
452 Part::NoseCone(nose) => &[(nose.base_radius_m, AutoDimension::BaseRadius)],
453 Part::BodyTube(tube) => &[(tube.outer_radius_m, AutoDimension::OuterRadius)],
454 Part::Transition(t) => &[
455 (t.fore_radius_m, AutoDimension::ForeRadius),
456 (t.aft_radius_m, AutoDimension::AftRadius),
457 ],
458 _ => &[],
459 };
460 for &(radius_m, dimension) in radii {
461 if !pod.auto.contains(&dimension) {
462 widest = Some(widest.map_or(radius_m, |w: f64| w.max(radius_m)));
463 }
464 }
465 }
466 let Some(pod_radius_m) = widest.or(pods.is_empty().then_some(0.0)) else {
468 values.warn_at(
469 WarningKind::Skipped,
470 format!(
471 "a pod whose radii are all automatic, with no fixed radius in the pod to take; \
472 the pod set was left out{}",
473 and_what_was_inside(element)
474 ),
475 );
476 return None;
477 };
478 let tube_radius_m = tube.outer_radius_m;
479 let method = method.unwrap_or_else(|| {
480 values.warn_at(
481 WarningKind::Unusual,
482 "no `radiusoffset`, so the pods were read touching the tube",
483 );
484 "surface".to_owned()
485 });
486 let Some(number) = number.or((method != "free").then_some(0.0)) else {
489 values.warn_at(
490 WarningKind::Skipped,
491 format!(
492 "a `free` `radiusoffset` with no number, so where the pods sit is not known; the \
493 pod set was left out{}",
494 and_what_was_inside(element)
495 ),
496 );
497 return None;
498 };
499 let radial_offset_m = match method.as_str() {
500 "relative" => tube_radius_m + pod_radius_m + number,
501 "surface" => tube_radius_m + pod_radius_m,
502 "free" => number,
503 other => {
504 values.warn_at(
505 WarningKind::Unusual,
506 format!(
507 "a `radiusoffset` measured by `{other}`, which this reader does not know; it \
508 was read as `relative`, from the tube's surface to the pod's"
509 ),
510 );
511 values.forget(&["radiusoffset"]);
513 tube_radius_m + pod_radius_m + number
514 }
515 };
516 if method != "free" && parent_auto.contains(&AutoDimension::OuterRadius) {
517 if tube_radius_m <= 0.0 {
518 values.warn_at(
519 WarningKind::Skipped,
520 format!(
521 "the tube's radius is automatic with no number cached, and hpr places pods at \
522 a fixed distance from the axis, so where they sit is not known; the pod set \
523 was left out{}",
524 and_what_was_inside(element)
525 ),
526 );
527 return None;
528 }
529 values.warn_at(
530 WarningKind::Unusual,
531 format!(
532 "the tube's radius is automatic, and hpr places pods at a fixed distance from the \
533 axis: they were placed {radial_offset_m} m from it, by the tube radius OpenRocket \
534 cached, {tube_radius_m} m"
535 ),
536 );
537 values.forget(&["radiusoffset"]);
540 }
541 if !(radial_offset_m >= 0.0 && radial_offset_m.is_finite()) {
542 values.warn_at(
543 WarningKind::Skipped,
544 format!(
545 "the pods would sit {radial_offset_m} m from the axis, which is no distance; the \
546 pod set was left out{}",
547 and_what_was_inside(element)
548 ),
549 );
550 return None;
551 }
552
553 if pods.is_empty() && (overrides != Overrides::default() || drag_override.is_some()) {
555 overrides = Overrides::default();
558 drag_override = None;
559 values.warn_at(
560 WarningKind::Dropped,
561 "an override on a pod set that holds nothing, which OpenRocket weighs at the rocket's \
562 tip; it was left out",
563 );
564 values.forget(&["overridemass", "overridecg", "overridecd"]);
565 values.forget(&OVERRIDE_FLAGS);
566 } else if overrides != Overrides::default() && !include_children {
567 values.warn_at(
570 WarningKind::Dropped,
571 "an override on a pod set that does not cover its pods; a pod set weighs nothing of \
572 its own, so it was read as covering them",
573 );
574 values.forget(&OVERRIDE_FLAGS);
575 }
576 for pod in pods.iter_mut().filter(|pod| flat(pod)) {
579 let radius_m = match &pod.part {
580 Part::BodyTube(tube) => tube.outer_radius_m,
581 _ => 0.0,
582 };
583 for child in &mut pod.children {
584 if let Part::FinSet(fins) = &mut child.part
585 && fins.tab.as_ref().is_some_and(|tab| tab.height_m > radius_m)
586 {
587 fins.tab = None;
588 values.warn_at(
589 WarningKind::Dropped,
590 format!(
591 "a tab on the fins `{}` on a pod's tube of no length, deeper than the \
592 tube's radius ({radius_m} m); the tab was left out, and the fins read \
593 without it",
594 child.id
595 ),
596 );
597 }
598 }
599 }
600
601 ids.read.insert(at.to_owned());
602 Some(Component {
603 id,
604 name,
605 part: Part::PodSet(PodSet {
606 count,
607 radial_offset_m,
608 angle_rad,
609 }),
610 position: Some(position),
611 auto: Vec::new(),
612 motor_mount: None,
613 finish: None,
614 overrides,
615 overrides_include_children: true,
616 drag_override,
617 children: pods,
618 })
619}
620
621fn parallel_stage(
634 element: &Element,
635 parent: &Part,
636 parent_auto: &[AutoDimension],
637 at: &str,
638 ids: &mut Ids,
639 skipped: &mut Vec<String>,
640 warnings: &mut Vec<Warning>,
641) -> Option<Stage> {
642 let leave_out = |why: &str, warnings: &mut Vec<Warning>| {
643 Values::new(element, at, warnings).warn_at(
644 WarningKind::Skipped,
645 format!(
646 "a parallel stage {why}, which hpr does not read yet; it was left out{}",
647 and_what_was_inside(element)
648 ),
649 );
650 };
651 if !ids.parallel_stages_read {
652 leave_out("on a rocket of more than one axial stage", warnings);
653 return None;
654 }
655 if at.matches("/podset[").count() + at.matches("/parallelstage[").count() > 1 {
656 leave_out("inside a pod or another parallel stage", warnings);
657 return None;
658 }
659 if !subcomponents(element).any(|child| BODY_TAGS.contains(&child.name.as_str())) {
661 leave_out("with no nose cone, body tube or transition in it", warnings);
662 return None;
663 }
664 let mut scratch = Vec::new();
667 if matches!(
668 position(&mut Values::new(element, at, &mut scratch)),
669 Position::After { .. }
670 ) {
671 leave_out("placed after the part before it", warnings);
672 return None;
673 }
674 let first = warnings.len();
675 let pods = pod_set(element, parent, parent_auto, at, ids, skipped, warnings);
676 for warning in &mut warnings[first..] {
677 if warning.at == at {
678 warning.message = warning.message.replace("pod set", "parallel stage");
679 }
680 }
681 let pods = pods?;
682 let Part::PodSet(set) = pods.part else {
683 unreachable!("`pod_set` reads a pod set")
684 };
685 let position = pods.position.unwrap_or(Position::Top { aft_offset_m: 0.0 });
687 if let Some(separation) = recovery::separation(element, at, warnings) {
688 ids.separations.push((pods.id.clone(), separation));
689 }
690 Some(Stage {
691 id: pods.id,
692 name: pods.name,
693 components: pods.children,
694 overrides: pods.overrides,
695 drag_override: pods.drag_override,
696 parallel: Some(ParallelStage {
697 on: String::new(),
698 position,
699 pods: set,
700 }),
701 })
702}
703
704const INNER_TUBE_UNRESOLVED_M: f64 = 0.0095;
711
712fn inner_tube(tag: &str, values: &mut Values<'_>, auto: &mut Vec<AutoDimension>) -> Option<Part> {
714 let length_m = values.number(&["length"]).unwrap_or_default();
715 let (mut stated_m, mut outer_radius_m) =
716 stated_radius(values, &["outerradius"], AutoDimension::OuterRadius, auto);
717 if tag == "innertube" && auto.contains(&AutoDimension::OuterRadius) {
718 auto.retain(|dimension| *dimension != AutoDimension::OuterRadius);
719 (stated_m, outer_radius_m) = (Some(INNER_TUBE_UNRESOLVED_M), INNER_TUBE_UNRESOLVED_M);
722 }
723 let thickness_m = tube_wall(values, stated_m)?;
724 let angle_rad = roll_angle(values);
725 let cluster_m = cluster(values, outer_radius_m, angle_rad);
726 Some(Part::InnerTube(InnerTube {
727 length_m,
728 outer_radius_m,
729 thickness_m,
730 radial_offset_m: values.number(&["radialposition"]).unwrap_or_default(),
731 angle_rad,
732 material: material(values, &["material"], "bulk"),
733 cluster_m,
734 }))
735}
736
737fn cluster(values: &mut Values<'_>, outer_radius_m: f64, angle_rad: f64) -> Vec<[f64; 2]> {
753 let Some(name) = values.word(&["clusterconfiguration"]) else {
754 return Vec::new();
755 };
756 let Some(points) = cluster_pattern(&name) else {
757 values.warn_at(
758 WarningKind::Dropped,
759 format!(
760 "`{name}` is not one of OpenRocket's cluster patterns, so the tube is read as one \
761 tube, as OpenRocket reads it"
762 ),
763 );
764 values.forget(&["clusterconfiguration"]);
765 return Vec::new();
766 };
767 let scale = values.number(&["clusterscale"]).unwrap_or(1.0);
768 let rotation_rad = values
769 .number(&["clusterrotation"])
770 .unwrap_or_default()
771 .to_radians();
772 if name == "single" {
773 return Vec::new();
774 }
775 let separation_m = 2.0 * outer_radius_m * scale;
776 let (sin, cos) = (angle_rad - rotation_rad).sin_cos();
777 points
778 .iter()
779 .map(|&[u, v]| {
780 [
781 separation_m * (u * cos - v * sin),
782 separation_m * (u * sin + v * cos),
783 ]
784 })
785 .collect()
786}
787
788pub(crate) fn cluster_pattern(name: &str) -> Option<Vec<[f64; 2]>> {
796 let ring = |n: u32, r: f64, start: f64| -> Vec<[f64; 2]> {
798 (0..n)
799 .map(|k| {
800 let (sin, cos) = (start - 360.0 * f64::from(k) / f64::from(n))
801 .to_radians()
802 .sin_cos();
803 [r * cos, r * sin]
804 })
805 .collect()
806 };
807 let star = |mut points: Vec<[f64; 2]>| {
808 points.insert(0, [0.0, 0.0]);
809 points
810 };
811 let third = 3.0_f64.sqrt() / 6.0;
812 Some(match name {
813 "single" => vec![[0.0, 0.0]],
814 "double" => vec![[-0.5, 0.0], [0.5, 0.0]],
815 "3-row" => vec![[-1.0, 0.0], [0.0, 0.0], [1.0, 0.0]],
816 "3-ring" => vec![[-0.5, -third], [0.5, -third], [0.0, 2.0 * third]],
817 "3-star" => star(ring(3, 1.0, 90.0)),
818 "4-row" => vec![[-1.5, 0.0], [-0.5, 0.0], [0.5, 0.0], [1.5, 0.0]],
819 "4-ring" => vec![[-0.5, 0.5], [0.5, 0.5], [0.5, -0.5], [-0.5, -0.5]],
820 "4-star" => star(ring(4, 1.0, 135.0)),
821 "5-ring" => ring(5, 0.5 / 36.0_f64.to_radians().sin(), 90.0),
823 "5-star" => star(ring(5, 1.0, 90.0)),
824 "6-ring" => ring(6, 1.0, 90.0),
825 "6-star" => star(ring(6, 1.0, 90.0)),
826 "9-grid" => [1.4, 0.0, -1.4]
827 .into_iter()
828 .flat_map(|y| [-1.4, 0.0, 1.4].map(|x| [x, y]))
829 .collect(),
830 "9-star" => star(ring(8, 1.4, 0.0)),
831 _ => return None,
832 })
833}
834
835fn tube_wall(values: &mut Values<'_>, outer_radius_m: Option<f64>) -> Option<f64> {
847 if values.word(&["thickness"]).as_deref() == Some("filled") {
848 return match outer_radius_m {
849 Some(radius_m) => Some(radius_m),
850 None => {
851 values.warn_at(
852 WarningKind::Skipped,
853 "a filled tube whose outer radius is automatic; there is no radius to fill \
854 until the layout resolves one, so it was left out",
855 );
856 None
857 }
858 };
859 }
860 let Some(thickness_m) = values.number(&["thickness"]) else {
861 values.warn_at(
868 WarningKind::Dropped,
869 "a tube with no wall thickness at all; it was read as a tube of no wall, which \
870 carries no mass, rather than as the solid rod a filled one would be",
871 );
872 return Some(0.0);
873 };
874 if thickness_m == 0.0 {
875 return Some(0.0);
876 }
877 if thickness_m < 0.0 {
878 values.warn_at(
879 WarningKind::Skipped,
880 "a tube of negative wall thickness; it was left out",
881 );
882 return None;
883 }
884 Some(match outer_radius_m {
887 Some(radius_m) => thickness_m.min(radius_m),
888 None => thickness_m,
889 })
890}
891
892fn ring(values: &mut Values<'_>, auto: &mut Vec<AutoDimension>, bored: bool) -> Option<Part> {
894 let length_m = values.number(&["length"]).unwrap_or_default();
895 let (_, outer_radius_m) =
896 stated_radius(values, &["outerradius"], AutoDimension::OuterRadius, auto);
897 let inner_radius_m = if bored {
898 stated_radius(values, &["innerradius"], AutoDimension::InnerRadius, auto).1
899 } else {
900 0.0
901 };
902 if inner_radius_m > outer_radius_m && !auto.contains(&AutoDimension::OuterRadius) {
903 values.warn_at(
904 WarningKind::Skipped,
905 "a ring whose bore is wider than the ring; it was left out",
906 );
907 return None;
908 }
909 instanced_once(values, "ring");
910 off_the_axis(values, "ring");
911 Some(Part::CenteringRing(CenteringRing {
912 length_m,
913 outer_radius_m,
914 inner_radius_m,
915 material: material(values, &["material"], "bulk"),
916 }))
917}
918
919fn fin_set(element: &Element, values: &mut Values<'_>) -> Option<Part> {
921 let planform = match element.name.as_str() {
922 "trapezoidfinset" => FinPlanform::Trapezoidal {
923 root_chord_m: values.number(&["rootchord"]).unwrap_or_default(),
924 tip_chord_m: values.number(&["tipchord"]).unwrap_or_default(),
925 span_m: values.number(&["height"]).unwrap_or_default(),
926 sweep_m: values.number(&["sweeplength"]).unwrap_or_default(),
927 },
928 "ellipticalfinset" => FinPlanform::Elliptical {
929 root_chord_m: values.number(&["rootchord"]).unwrap_or_default(),
930 span_m: values.number(&["height"]).unwrap_or_default(),
931 },
932 _ => outline(values)?,
933 };
934 let count = instances(values, "fin set")?;
935 let fillet = values
938 .number(&["filletradius"])
939 .filter(|radius_m| *radius_m > 0.0)
940 .map(|radius_m| FinFillet {
941 radius_m,
942 material: material(values, &["filletmaterial"], "bulk"),
943 });
944 Some(Part::FinSet(FinSet {
945 count,
946 thickness_m: values.number(&["thickness"]).unwrap_or_default(),
947 cross_section: cross_section(values),
948 tab: tab(values, planform.root_chord_m()),
949 fillet,
950 cant_rad: values.number(&["cant"]).unwrap_or_default().to_radians(),
955 base_angle_rad: roll_angle(values),
956 material: material(values, &["material"], "bulk"),
957 planform,
958 }))
959}
960
961pub(super) const ROOT_PIECES: u32 = 64;
965
966fn follow_the_body(
984 part: Part,
985 parent: &Part,
986 parent_auto: &[AutoDimension],
987 position: Position,
988) -> Result<Part, String> {
989 let Part::FinSet(mut fins) = part else {
990 return Ok(part);
991 };
992 let surface = match parent {
993 Part::NoseCone(nose) => nose.profile(),
994 Part::Transition(transition) => transition.profile(),
995 _ => {
996 return match fins.check_level_root() {
997 Ok(()) => Ok(Part::FinSet(fins)),
998 Err(_) => Err(
999 "a freeform fin outline that does not run from the root leading \
1000 edge to the root trailing edge; it was left out rather than closed along a \
1001 root it never touches"
1002 .to_owned(),
1003 ),
1004 };
1005 }
1006 };
1007 let on = spoken(parent);
1008 let automatic = parent_auto.iter().any(|dimension| {
1009 matches!(
1010 dimension,
1011 AutoDimension::BaseRadius | AutoDimension::ForeRadius | AutoDimension::AftRadius
1012 )
1013 });
1014 if automatic {
1015 return Err(format!(
1016 "a fin set on a {on} of automatic radius, whose surface the layout settles only after \
1017 this reader draws the root; it was left out"
1018 ));
1019 }
1020 let surface = surface.map_err(|e| {
1021 format!("a fin set on a {on} whose surface is unreadable ({e}); it was left out")
1022 })?;
1023 if fins.tab.is_some() || fins.fillet.is_some() {
1024 return Err(format!(
1025 "a fin set with a tab or a fillet on a {on}, which hpr models only on a body tube; it \
1026 was left out"
1027 ));
1028 }
1029 let chord = fins.planform.root_chord_m();
1030 let FinPlanform::Freeform { points_m, root_m } = &mut fins.planform else {
1031 return Err(format!(
1032 "a {} fin set on a {on}: OpenRocket runs its root along the surface, which hpr reads \
1033 only from a freeform outline; it was left out",
1034 match fins.planform {
1035 FinPlanform::Trapezoidal { .. } => "trapezoidal",
1036 FinPlanform::Elliptical { .. } => "elliptical",
1037 _ => "non-freeform",
1038 }
1039 ));
1040 };
1041 let length = surface.length_m();
1042 let fore = match position {
1043 Position::Top { aft_offset_m } => aft_offset_m,
1044 Position::Middle { aft_offset_m } => 0.5 * (length - chord) + aft_offset_m,
1045 Position::Bottom { aft_offset_m } => length - chord + aft_offset_m,
1046 Position::After { .. } | Position::Absolute { .. } => {
1047 return Err(format!(
1048 "a fin set on a {on} placed after its sibling or at an absolute station, where this \
1049 reader can't find the surface its root follows; it was left out"
1050 ));
1051 }
1052 };
1053 let slack = FinSet::ROOT_ON_SURFACE_M;
1055 if !(fore >= -slack && fore + chord <= length + slack) {
1056 return Err(format!(
1057 "a fin set whose root runs past the ends of the {on} it sits on; it was left out"
1058 ));
1059 }
1060 let base = surface.radius_m(fore);
1061 let rise = |x: f64| surface.radius_m(fore + x) - base;
1062 let pieces = f64::from(ROOT_PIECES);
1063 let stations = (1..=ROOT_PIECES).map(|i| chord * f64::from(i) / pieces);
1064 if stations.clone().any(|x| rise(x) < 0.0) {
1065 return Err(format!(
1066 "a fin set on a {on} that narrows along its root, which hpr reads only where the body \
1067 holds or grows; it was left out"
1068 ));
1069 }
1070 let end = points_m.last().copied().unwrap_or_default();
1071 let miss = end[1] - rise(chord);
1072 if miss.abs() > FinSet::ROOT_ON_SURFACE_M {
1073 return Err(format!(
1074 "a freeform fin outline on a {on} that ends {miss} m off its surface, where \
1075 OpenRocket's root ends; it was left out"
1076 ));
1077 }
1078 *root_m = stations
1079 .take(ROOT_PIECES as usize - 1)
1080 .map(|x| [x, rise(x)])
1081 .collect();
1082 root_m.reverse();
1083 fins.root_radius_on(&surface, fore).map_err(|e| {
1086 format!("a fin set on a {on} whose root can't be drawn on it ({e}); it was left out")
1087 })?;
1088 Ok(Part::FinSet(fins))
1089}
1090
1091fn outline(values: &mut Values<'_>) -> Option<FinPlanform> {
1094 let Some(points) = values.element(&["finpoints"]) else {
1095 values.warn_at(
1096 WarningKind::Skipped,
1097 "a freeform fin set with no outline; it was left out",
1098 );
1099 return None;
1100 };
1101 let mut points_m = Vec::new();
1102 super::reads::note(points, "point");
1103 for point in points.elements().filter(|point| point.name == "point") {
1104 let read = |name: &str| {
1105 point
1106 .attribute(name)
1107 .and_then(|text| text.parse::<f64>().ok())
1108 .filter(|value| value.is_finite())
1109 };
1110 match (read("x"), read("y")) {
1111 (Some(x), Some(h)) => points_m.push([x, h]),
1112 _ => {
1113 values.warn_at(
1114 WarningKind::Skipped,
1115 "a freeform fin outline with a point that is not two numbers; it was left out",
1116 );
1117 return None;
1118 }
1119 }
1120 }
1121 if points_m.len() < 3 {
1122 values.warn_at(
1123 WarningKind::Skipped,
1124 "a freeform fin outline of fewer than three points, which encloses no fin; it was \
1125 left out",
1126 );
1127 return None;
1128 }
1129 let ends_aft = matches!(points_m.last(), Some(last) if last[0] > 0.0);
1130 if points_m.first() != Some(&[0.0, 0.0]) || !ends_aft {
1131 values.warn_at(
1132 WarningKind::Skipped,
1133 "a freeform fin outline that does not run from the root leading edge to the root \
1134 trailing edge; it was left out rather than closed along a root it never touches",
1135 );
1136 return None;
1137 }
1138 Some(FinPlanform::Freeform {
1139 points_m,
1140 root_m: Vec::new(),
1141 })
1142}
1143
1144fn cross_section(values: &mut Values<'_>) -> FinCrossSection {
1146 match values.word(&["crosssection"]).as_deref() {
1147 Some("rounded") => FinCrossSection::Rounded,
1148 Some("airfoil") => FinCrossSection::Airfoil,
1149 None | Some("square") => FinCrossSection::Square,
1150 Some(other) => {
1151 let other = other.to_owned();
1152 values.warn_at(
1153 WarningKind::Unusual,
1154 format!("`{other}` is not a fin section this reader knows; it was read as square"),
1155 );
1156 values.forget(&["crosssection"]);
1157 FinCrossSection::Square
1158 }
1159 }
1160}
1161
1162fn tab(values: &mut Values<'_>, root_chord_m: f64) -> Option<FinTab> {
1174 let height_m = values.number(&["tabheight"])?;
1175 let length_m = values.number(&["tablength"]).unwrap_or_default();
1176 if height_m <= 0.0 || length_m <= 0.0 {
1177 return None;
1178 }
1179 let element = values.element(&["tabposition"]);
1180 let relative_to = element
1181 .and_then(|element| element.attribute("relativeto"))
1182 .unwrap_or("front")
1183 .to_owned();
1184 let offset_m = values.number(&["tabposition"]).unwrap_or_default();
1185 let offset_m = match relative_to.as_str() {
1186 "front" | "top" => offset_m,
1191 "center" | "middle" => 0.5 * (root_chord_m - length_m) + offset_m,
1192 "end" | "bottom" => root_chord_m - length_m + offset_m,
1193 other => {
1194 values.warn_at(
1195 WarningKind::Unusual,
1196 format!(
1197 "a fin tab measured from `{other}`, which this reader does not know; it was \
1198 read from the root leading edge"
1199 ),
1200 );
1201 values.forget(&["tabposition"]);
1202 offset_m
1203 }
1204 };
1205 Some(FinTab {
1206 height_m,
1207 length_m,
1208 offset_m,
1209 })
1210}
1211
1212const MOST_TUBE_FINS: u32 = 8;
1215
1216fn tube_fins(values: &mut Values<'_>, auto: &mut Vec<AutoDimension>) -> Option<Part> {
1219 let (stated_m, outer_radius_m) =
1220 stated_radius(values, &["radius"], AutoDimension::OuterRadius, auto);
1221 let thickness_m = tube_wall(values, stated_m)?;
1222 let count = match values.count(&INSTANCE_COUNT).unwrap_or(1) {
1225 0 => {
1226 values.warn_at(
1227 WarningKind::Skipped,
1228 "a tube fin set of none; it was left out",
1229 );
1230 return None;
1231 }
1232 written if written > MOST_TUBE_FINS => {
1233 values.warn_at(
1234 WarningKind::Dropped,
1235 format!(
1236 "a tube fin set of {written} tubes; OpenRocket 24.12 reads at most \
1237 {MOST_TUBE_FINS}, so it was read as {MOST_TUBE_FINS}"
1238 ),
1239 );
1240 values.forget(&INSTANCE_COUNT);
1241 MOST_TUBE_FINS
1242 }
1243 written => written,
1244 };
1245 Some(Part::TubeFinSet(TubeFinSet {
1246 count,
1247 length_m: values.number(&["length"]).unwrap_or_default(),
1248 outer_radius_m,
1249 thickness_m,
1250 base_angle_rad: roll_angle(values),
1251 material: material(values, &["material"], "bulk"),
1252 }))
1253}
1254
1255fn launch_lug(values: &mut Values<'_>) -> Option<Part> {
1257 let mut auto = Vec::new();
1258 let (stated_m, outer_radius_m) =
1259 stated_radius(values, &["radius"], AutoDimension::OuterRadius, &mut auto);
1260 if stated_m.is_none() {
1261 values.warn_at(
1267 WarningKind::Skipped,
1268 "a launch lug whose radius OpenRocket works out for itself; hpr does not resolve that, \
1269 so it was left out",
1270 );
1271 return None;
1272 }
1273 let thickness_m = tube_wall(values, stated_m)?;
1274 let (count, spacing_m) = row(values, "row of launch lugs")?;
1275 Some(Part::LaunchLug(LaunchLug {
1276 length_m: values.number(&["length"]).unwrap_or_default(),
1277 outer_radius_m,
1278 thickness_m,
1279 angle_rad: roll_angle(values),
1280 count,
1281 spacing_m,
1282 material: material(values, &["material"], "bulk"),
1283 }))
1284}
1285
1286fn rail_button(values: &mut Values<'_>) -> Option<Part> {
1288 let (count, spacing_m) = row(values, "row of rail buttons")?;
1289 Some(Part::RailButton(RailButton {
1290 outer_diameter_m: values.number(&["outerdiameter"]).unwrap_or_default(),
1291 inner_diameter_m: values.number(&["innerdiameter"]).unwrap_or_default(),
1292 height_m: values.number(&["height"]).unwrap_or_default(),
1293 base_height_m: values.number(&["baseheight"]).unwrap_or_default(),
1294 flange_height_m: values.number(&["flangeheight"]).unwrap_or_default(),
1295 screw_height_m: values.number(&["screwheight"]).unwrap_or_default(),
1296 angle_rad: roll_angle(values),
1297 count,
1298 spacing_m,
1299 material: material_or(values, &["material"], "bulk", UNNAMED_RAIL_BUTTON),
1300 }))
1301}
1302
1303fn mass_component(values: &mut Values<'_>, auto: &mut Vec<AutoDimension>) -> Option<Part> {
1305 Some(Part::MassComponent(MassComponent {
1306 mass_kg: values.number(&["mass"]).unwrap_or_default(),
1307 packing: packing(values, auto),
1308 }))
1309}
1310
1311fn parachute(values: &mut Values<'_>, auto: &mut Vec<AutoDimension>) -> Option<Part> {
1313 Some(Part::Parachute(Parachute {
1314 diameter_m: values.number(&["diameter"]).unwrap_or_default(),
1315 canopy_material: material(values, &["material"], "surface"),
1316 line_count: values.count(&["linecount"]).unwrap_or_default(),
1317 line_length_m: values.number(&["linelength"]).unwrap_or_default(),
1318 line_material: material(values, &["linematerial"], "line"),
1319 packing: packing(values, auto),
1320 }))
1321}
1322
1323fn streamer(values: &mut Values<'_>, auto: &mut Vec<AutoDimension>) -> Option<Part> {
1325 Some(Part::Streamer(Streamer {
1326 length_m: values.number(&["striplength"]).unwrap_or_default(),
1327 width_m: values.number(&["stripwidth"]).unwrap_or_default(),
1328 material: material(values, &["material"], "surface"),
1329 packing: packing(values, auto),
1330 }))
1331}
1332
1333fn shock_cord(values: &mut Values<'_>, auto: &mut Vec<AutoDimension>) -> Option<Part> {
1335 Some(Part::ShockCord(ShockCord {
1336 length_m: values.number(&["cordlength"]).unwrap_or_default(),
1337 material: material(values, &["material"], "line"),
1338 packing: packing(values, auto),
1339 }))
1340}
1341
1342const PACKED_LENGTH_M: f64 = 0.025;
1353
1354const PACKED_RADIUS_M: f64 = 0.0125;
1357
1358fn packing(values: &mut Values<'_>, auto: &mut Vec<AutoDimension>) -> Packing {
1365 let radius_m = if values.element(&["packedradius"]).is_none() {
1366 PACKED_RADIUS_M
1367 } else {
1368 stated_radius(values, &["packedradius"], AutoDimension::PackedRadius, auto).1
1369 };
1370 let length_m = if values.element(&["packedlength"]).is_none() {
1371 PACKED_LENGTH_M
1372 } else {
1373 values.number(&["packedlength"]).unwrap_or_default()
1374 };
1375 Packing {
1376 length_m,
1377 radius_m,
1378 radial_offset_m: values.number(&["radialposition"]).unwrap_or_default(),
1379 angle_rad: roll_angle(values),
1380 }
1381}
1382
1383fn row(values: &mut Values<'_>, what: &str) -> Option<(u32, f64)> {
1386 let count = instances(values, what)?;
1387 Some((
1388 count.max(1),
1389 values.number(&["instanceseparation"]).unwrap_or_default(),
1390 ))
1391}
1392
1393fn instances(values: &mut Values<'_>, what: &str) -> Option<u32> {
1396 let count = values.count(&INSTANCE_COUNT).unwrap_or(1);
1397 if count == 0 {
1398 let what = what.to_owned();
1399 values.warn_at(
1400 WarningKind::Skipped,
1401 format!("a {what} of none; it was left out"),
1402 );
1403 return None;
1404 }
1405 if count > MOST_INSTANCES {
1406 let what = what.to_owned();
1407 values.warn_at(
1408 WarningKind::Skipped,
1409 format!(
1410 "a {what} of {count}, where hpr builds each one and adds them up; it was left out \
1411 rather than asked for"
1412 ),
1413 );
1414 return None;
1415 }
1416 Some(count)
1417}
1418
1419fn instanced_once(values: &mut Values<'_>, what: &str) {
1421 if values.count(&INSTANCE_COUNT).is_some_and(|count| count > 1) {
1422 let what = what.to_owned();
1423 values.warn_at(
1424 WarningKind::Dropped,
1425 format!("a row of more than one {what} was read as the one it is written as"),
1426 );
1427 values.forget(&INSTANCE_COUNT);
1428 }
1429}
1430
1431fn off_the_axis(values: &mut Values<'_>, what: &str) {
1433 if values
1434 .number(&["radialposition"])
1435 .is_some_and(|offset| offset != 0.0)
1436 {
1437 let what = what.to_owned();
1438 values.warn_at(
1439 WarningKind::Dropped,
1440 format!("a {what} off the body axis was read on it; hpr keeps one on the axis"),
1441 );
1442 values.forget(&["radialposition"]);
1443 }
1444}
1445
1446fn radial_offset_on_the_surface(values: &mut Values<'_>, part: &Part) {
1453 if !part.is_external() {
1454 return;
1455 }
1456 if values
1457 .number(&["radiusoffset"])
1458 .is_some_and(|offset| offset != 0.0)
1459 {
1460 let kind = spoken(part);
1461 values.warn_at(
1462 WarningKind::Dropped,
1463 format!("a {kind} standing off the body was read sitting on it"),
1464 );
1465 values.forget(&["radiusoffset"]);
1466 }
1467}
1468
1469fn position(values: &mut Values<'_>) -> Position {
1476 let Some(element) = values.element(&AXIAL_OFFSET) else {
1477 values.warn_at(
1478 WarningKind::Unusual,
1479 "no axial offset at all; the part was read flush with its parent's forward end",
1480 );
1481 return Position::Top { aft_offset_m: 0.0 };
1482 };
1483 let from = element
1484 .attribute("method")
1485 .or_else(|| element.attribute("type"))
1486 .unwrap_or("top")
1487 .to_owned();
1488 let text = element.text();
1491 let aft_offset_m = match text.trim().parse::<f64>() {
1492 Ok(value) if value.is_finite() => value,
1493 _ => {
1494 let (name, text) = (element.name.clone(), text.trim().to_owned());
1495 values.warn_at(
1496 WarningKind::Dropped,
1497 format!("`{name}` says `{text}`, which is not a number; it was read as zero"),
1498 );
1499 values.forget(&AXIAL_OFFSET);
1500 0.0
1501 }
1502 };
1503 match from.as_str() {
1504 "top" => Position::Top { aft_offset_m },
1505 "middle" => Position::Middle { aft_offset_m },
1506 "bottom" => Position::Bottom { aft_offset_m },
1507 "after" => Position::After { aft_offset_m },
1508 "absolute" => Position::Absolute {
1509 station_m: aft_offset_m,
1510 },
1511 other => {
1512 values.warn_at(
1513 WarningKind::Unusual,
1514 format!(
1515 "an axial offset measured from `{other}`, which this reader does not know; it \
1516 was read from the parent's forward end"
1517 ),
1518 );
1519 values.forget(&AXIAL_OFFSET);
1520 Position::Top { aft_offset_m }
1521 }
1522 }
1523}
1524
1525fn centered_on_its_position(position: Position, button: &RailButton) -> Position {
1536 let radius_m = 0.5 * button.outer_diameter_m;
1537 let row_m = button.spacing_m * f64::from(button.count.saturating_sub(1));
1538 match position {
1539 Position::Top { aft_offset_m } => Position::Top {
1540 aft_offset_m: aft_offset_m - radius_m,
1541 },
1542 Position::Middle { aft_offset_m } => Position::Middle {
1543 aft_offset_m: aft_offset_m + 0.5 * row_m,
1544 },
1545 Position::Bottom { aft_offset_m } => Position::Bottom {
1546 aft_offset_m: aft_offset_m + radius_m + row_m,
1547 },
1548 Position::After { aft_offset_m } => Position::After {
1549 aft_offset_m: aft_offset_m - radius_m,
1550 },
1551 Position::Absolute { station_m } => Position::Absolute {
1552 station_m: station_m - radius_m,
1553 },
1554 }
1555}
1556
1557pub(super) fn finish(values: &mut Values<'_>) -> Option<Finish> {
1580 let word = values.word(&["finish"])?;
1581 let roughness_m = match word.as_str() {
1582 "rough" => 500e-6,
1583 "unfinished" => 150e-6,
1584 "normal" => 60e-6,
1585 "smooth" => 20e-6,
1586 "polished" => 2e-6,
1587 other => {
1588 let other = other.to_owned();
1589 values.warn_at(
1590 WarningKind::Unusual,
1591 format!(
1592 "`{other}` is not a surface finish this reader has a roughness for; the part \
1593 took hpr's default"
1594 ),
1595 );
1596 values.forget(&["finish"]);
1597 return None;
1598 }
1599 };
1600 Some(Finish::Custom { roughness_m })
1601}
1602
1603fn roll_angle(values: &mut Values<'_>) -> f64 {
1636 let newer = values.number(&["angleoffset"]);
1637 let older = values
1638 .number(&["rotation"])
1639 .or_else(|| values.number(&["radialdirection"]));
1640 if let (Some(newer), Some(older)) = (newer, older)
1641 && newer != older
1642 {
1643 values.warn_at(
1644 WarningKind::Dropped,
1645 format!(
1646 "`angleoffset` says `{newer}` and the older name says `{older}`; they are two \
1647 names for one angle, so `angleoffset` was taken"
1648 ),
1649 );
1650 values.forget(&["rotation", "radialdirection"]);
1651 }
1652 newer.or(older).unwrap_or_default().to_radians()
1653}
1654
1655#[cfg(test)]
1656mod tests {
1657 use super::*;
1658 use hpr_design::Material;
1659
1660 #[test]
1664 fn cluster_patterns_are_openrocket_s_points() {
1665 let text = include_str!("../../../../validation/fixtures/ork/openrocket-clusters.json");
1666 let record: serde_json::Value = serde_json::from_str(text).unwrap();
1667 let patterns = record["patterns"].as_object().unwrap();
1668 assert_eq!(patterns.len(), 14);
1669 for (name, pattern) in patterns {
1670 let theirs: Vec<f64> = pattern["points"]
1671 .as_array()
1672 .unwrap()
1673 .iter()
1674 .map(|v| v.as_f64().unwrap())
1675 .collect();
1676 let ours = cluster_pattern(name).unwrap_or_else(|| panic!("no pattern {name}"));
1677 assert_eq!(pattern["count"].as_u64(), Some(ours.len() as u64), "{name}");
1678 assert_eq!(theirs.len(), 2 * ours.len(), "{name}");
1679 for (k, [x, y]) in ours.iter().enumerate() {
1680 let apart = (x - theirs[2 * k]).hypot(y - theirs[2 * k + 1]);
1681 assert!(apart <= 1e-15, "{name} point {k}: {x}, {y}");
1682 }
1683 }
1684 assert_eq!(cluster_pattern("4-square"), None);
1685 }
1686
1687 #[test]
1692 fn a_rail_button_row_is_placed_by_its_first_center() {
1693 let button = RailButton {
1694 outer_diameter_m: 0.01,
1695 inner_diameter_m: 0.006,
1696 height_m: 0.008,
1697 base_height_m: 0.002,
1698 flange_height_m: 0.002,
1699 screw_height_m: 0.0,
1700 angle_rad: 0.0,
1701 count: 2,
1702 spacing_m: 0.1,
1703 material: Material::bulk("Probe", 1000.0),
1704 };
1705 let cases = [
1706 (
1708 Position::Top { aft_offset_m: 0.1 },
1709 Position::Top {
1710 aft_offset_m: 0.095,
1711 },
1712 ),
1713 (
1715 Position::Middle { aft_offset_m: 0.0 },
1716 Position::Middle { aft_offset_m: 0.05 },
1717 ),
1718 (
1721 Position::Bottom { aft_offset_m: -0.1 },
1722 Position::Bottom {
1723 aft_offset_m: 0.005,
1724 },
1725 ),
1726 (
1727 Position::After { aft_offset_m: 0.02 },
1728 Position::After {
1729 aft_offset_m: 0.015,
1730 },
1731 ),
1732 (
1733 Position::Absolute { station_m: 1.0 },
1734 Position::Absolute { station_m: 0.995 },
1735 ),
1736 ];
1737 for (read, placed) in cases {
1738 let found = centered_on_its_position(read, &button);
1739 let offset = |position: &Position| match *position {
1740 Position::Top { aft_offset_m }
1741 | Position::Middle { aft_offset_m }
1742 | Position::Bottom { aft_offset_m }
1743 | Position::After { aft_offset_m } => aft_offset_m,
1744 Position::Absolute { station_m } => station_m,
1745 };
1746 assert_eq!(
1747 std::mem::discriminant(&found),
1748 std::mem::discriminant(&placed),
1749 "{read:?}"
1750 );
1751 assert!(
1752 (offset(&found) - offset(&placed)).abs() < 1e-15,
1753 "{read:?}: {found:?}"
1754 );
1755 }
1756 }
1757}