1use std::error::Error;
63use std::fmt;
64
65use serde::{Deserialize, Serialize};
66
67pub const MAX_RETICLE_MARKS: usize = 4096;
75
76pub const OFF_RETICLE_MARGIN_FRACTION: f64 = 0.20;
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
85pub enum FocalPlane {
86 #[serde(rename = "ffp")]
88 #[default]
89 First,
90 #[serde(rename = "sfp")]
92 Second,
93}
94
95impl FocalPlane {
96 pub fn label(self) -> &'static str {
98 match self {
99 FocalPlane::First => "FFP",
100 FocalPlane::Second => "SFP",
101 }
102 }
103
104 pub fn is_magnification_dependent(self) -> bool {
106 matches!(self, FocalPlane::Second)
107 }
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
113#[serde(rename_all = "snake_case")]
114pub enum MarkKind {
115 #[default]
117 Dot,
118 Hash,
120 Post,
122 Center,
124}
125
126impl MarkKind {
127 pub fn as_str(self) -> &'static str {
129 match self {
130 MarkKind::Dot => "dot",
131 MarkKind::Hash => "hash",
132 MarkKind::Post => "post",
133 MarkKind::Center => "center",
134 }
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct ReticleMark {
145 pub down_mil: f64,
147 pub right_mil: f64,
149 #[serde(default)]
151 pub kind: MarkKind,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub label: Option<String>,
155}
156
157impl ReticleMark {
158 pub fn new(down_mil: f64, right_mil: f64, kind: MarkKind) -> Self {
160 Self {
161 down_mil,
162 right_mil,
163 kind,
164 label: None,
165 }
166 }
167
168 pub fn labeled(
170 down_mil: f64,
171 right_mil: f64,
172 kind: MarkKind,
173 label: impl Into<String>,
174 ) -> Self {
175 Self {
176 down_mil,
177 right_mil,
178 kind,
179 label: Some(label.into()),
180 }
181 }
182}
183
184#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct ReticleDescription {
191 pub name: String,
193 pub focal_plane: FocalPlane,
194 pub reference_magnification: f64,
198 pub marks: Vec<ReticleMark>,
199}
200
201#[derive(Debug, Clone, PartialEq)]
204pub enum ReticleError {
205 NonPositiveMagnification { magnification: f64 },
209 NonPositiveReferenceMagnification { reference_magnification: f64 },
213 NoMarks,
215 TooManyMarks { count: usize, max: usize },
217 NonFiniteMark { index: usize },
219 NonFiniteHold { drop_mil: f64, wind_mil: f64 },
221 InvalidGeneratorParameter {
223 parameter: &'static str,
224 value: f64,
225 rule: &'static str,
226 },
227 InvalidSpec(String),
231}
232
233impl fmt::Display for ReticleError {
234 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235 match self {
236 ReticleError::NonPositiveMagnification { magnification } => write!(
237 f,
238 "magnification must be finite and greater than zero (got {magnification})"
239 ),
240 ReticleError::NonPositiveReferenceMagnification {
241 reference_magnification,
242 } => write!(
243 f,
244 "an SFP reticle's reference magnification must be finite and greater than \
245 zero (got {reference_magnification})"
246 ),
247 ReticleError::NoMarks => {
248 write!(f, "the reticle description carries no marks")
249 }
250 ReticleError::TooManyMarks { count, max } => write!(
251 f,
252 "the reticle description carries {count} marks, more than the supported maximum of {max}"
253 ),
254 ReticleError::NonFiniteMark { index } => write!(
255 f,
256 "reticle mark {index} has non-finite coordinates"
257 ),
258 ReticleError::NonFiniteHold { drop_mil, wind_mil } => write!(
259 f,
260 "the hold must be finite (got drop {drop_mil} mil, wind {wind_mil} mil)"
261 ),
262 ReticleError::InvalidGeneratorParameter {
263 parameter,
264 value,
265 rule,
266 } => write!(f, "{parameter} must be {rule} (got {value})"),
267 ReticleError::InvalidSpec(reason) => {
268 write!(f, "invalid reticle specification: {reason}")
269 }
270 }
271 }
272}
273
274impl Error for ReticleError {}
275
276#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
278pub struct ReticleHold {
279 pub down_mil: f64,
282 pub right_mil: f64,
285 pub nearest_mark: Option<usize>,
289 pub nearest_mark_distance_mil: f64,
292 pub off_reticle: bool,
302 pub mark_scale: f64,
305}
306
307#[derive(Debug, Clone, Copy, PartialEq)]
309pub struct ScaledMark {
310 pub down_mil: f64,
311 pub right_mil: f64,
312}
313
314impl ReticleDescription {
315 pub fn validate(&self) -> Result<(), ReticleError> {
318 if self.marks.is_empty() {
319 return Err(ReticleError::NoMarks);
320 }
321 if self.marks.len() > MAX_RETICLE_MARKS {
322 return Err(ReticleError::TooManyMarks {
323 count: self.marks.len(),
324 max: MAX_RETICLE_MARKS,
325 });
326 }
327 for (index, mark) in self.marks.iter().enumerate() {
328 if !mark.down_mil.is_finite() || !mark.right_mil.is_finite() {
329 return Err(ReticleError::NonFiniteMark { index });
330 }
331 }
332 if self.focal_plane.is_magnification_dependent()
333 && (!self.reference_magnification.is_finite() || self.reference_magnification <= 0.0)
334 {
335 return Err(ReticleError::NonPositiveReferenceMagnification {
336 reference_magnification: self.reference_magnification,
337 });
338 }
339 Ok(())
340 }
341
342 pub fn mark_scale(&self, magnification: f64) -> f64 {
348 match self.focal_plane {
349 FocalPlane::First => 1.0,
350 FocalPlane::Second => self.reference_magnification / magnification,
351 }
352 }
353
354 pub fn scaled_marks(&self, magnification: f64) -> Result<Vec<ScaledMark>, ReticleError> {
356 self.validate()?;
357 require_positive_magnification(magnification)?;
358 let scale = self.mark_scale(magnification);
359 Ok(self
360 .marks
361 .iter()
362 .map(|mark| ScaledMark {
363 down_mil: mark.down_mil * scale,
364 right_mil: mark.right_mil * scale,
365 })
366 .collect())
367 }
368
369 pub fn mil_grid(spacing_mil: f64, extent_mil: f64) -> Result<Self, ReticleError> {
376 require_generator_positive("spacing", spacing_mil)?;
377 require_generator_positive("extent", extent_mil)?;
378 if extent_mil < spacing_mil {
379 return Err(ReticleError::InvalidGeneratorParameter {
380 parameter: "extent",
381 value: extent_mil,
382 rule: "greater than or equal to the spacing",
383 });
384 }
385 let steps = (extent_mil / spacing_mil).floor() as usize;
386 require_generated_size_checked(4usize.checked_mul(steps).and_then(|v| v.checked_add(1)))?;
389
390 let mut marks = Vec::with_capacity(1 + 4 * steps);
391 marks.push(ReticleMark::new(0.0, 0.0, MarkKind::Center));
392 for step in 1..=steps {
393 let offset = spacing_mil * step as f64;
394 marks.push(ReticleMark::new(offset, 0.0, MarkKind::Hash));
396 marks.push(ReticleMark::new(-offset, 0.0, MarkKind::Hash));
397 marks.push(ReticleMark::new(0.0, offset, MarkKind::Hash));
399 marks.push(ReticleMark::new(0.0, -offset, MarkKind::Hash));
400 }
401 Ok(Self {
402 name: format!("mil-cross {spacing_mil}/{extent_mil}"),
403 focal_plane: FocalPlane::First,
404 reference_magnification: 1.0,
405 marks,
406 })
407 }
408
409 pub fn tree(
415 rows: usize,
416 row_spacing_mil: f64,
417 spread_step_mil: f64,
418 ) -> Result<Self, ReticleError> {
419 if rows == 0 {
420 return Err(ReticleError::InvalidGeneratorParameter {
421 parameter: "rows",
422 value: 0.0,
423 rule: "at least 1",
424 });
425 }
426 require_generator_positive("row-spacing", row_spacing_mil)?;
427 require_generator_positive("spread-step", spread_step_mil)?;
428 require_generated_size_checked(
431 rows.checked_add(1)
432 .and_then(|r1| rows.checked_mul(r1))
433 .and_then(|p| p.checked_add(rows))
434 .and_then(|p| p.checked_add(1)),
435 )?;
436
437 let mut marks = Vec::new();
438 marks.push(ReticleMark::new(0.0, 0.0, MarkKind::Center));
439 for row in 1..=rows {
440 let down = row_spacing_mil * row as f64;
441 marks.push(ReticleMark::new(down, 0.0, MarkKind::Hash));
442 for step in 1..=row {
443 let spread = spread_step_mil * step as f64;
444 marks.push(ReticleMark::new(down, spread, MarkKind::Dot));
445 marks.push(ReticleMark::new(down, -spread, MarkKind::Dot));
446 }
447 }
448 Ok(Self {
449 name: format!("tree {rows}x{row_spacing_mil}/{spread_step_mil}"),
450 focal_plane: FocalPlane::First,
451 reference_magnification: 1.0,
452 marks,
453 })
454 }
455
456 pub fn bdc_from_drops(drops: &[(f64, f64)]) -> Result<Self, ReticleError> {
463 if drops.is_empty() {
464 return Err(ReticleError::InvalidGeneratorParameter {
465 parameter: "drops",
466 value: 0.0,
467 rule: "a non-empty list of (range, drop) pairs",
468 });
469 }
470 require_generated_size(1 + drops.len())?;
471 for &(range_m, drop_mil) in drops {
472 if !range_m.is_finite() || range_m <= 0.0 {
473 return Err(ReticleError::InvalidGeneratorParameter {
474 parameter: "drop range",
475 value: range_m,
476 rule: "finite and greater than zero",
477 });
478 }
479 if !drop_mil.is_finite() {
480 return Err(ReticleError::InvalidGeneratorParameter {
481 parameter: "drop",
482 value: drop_mil,
483 rule: "finite",
484 });
485 }
486 }
487
488 let mut marks = Vec::with_capacity(1 + drops.len());
489 marks.push(ReticleMark::new(0.0, 0.0, MarkKind::Center));
490 for &(range_m, drop_mil) in drops {
491 marks.push(ReticleMark::labeled(
492 drop_mil,
493 0.0,
494 MarkKind::Hash,
495 format!("{range_m} m"),
496 ));
497 }
498 Ok(Self {
499 name: format!("bdc {} marks", drops.len()),
500 focal_plane: FocalPlane::First,
501 reference_magnification: 1.0,
502 marks,
503 })
504 }
505}
506
507fn require_positive_magnification(magnification: f64) -> Result<(), ReticleError> {
508 if !magnification.is_finite() || magnification <= 0.0 {
509 return Err(ReticleError::NonPositiveMagnification { magnification });
510 }
511 Ok(())
512}
513
514fn require_generator_positive(parameter: &'static str, value: f64) -> Result<(), ReticleError> {
515 if !value.is_finite() || value <= 0.0 {
516 return Err(ReticleError::InvalidGeneratorParameter {
517 parameter,
518 value,
519 rule: "finite and greater than zero",
520 });
521 }
522 Ok(())
523}
524
525fn require_generated_size(count: usize) -> Result<(), ReticleError> {
526 if count > MAX_RETICLE_MARKS {
527 return Err(ReticleError::TooManyMarks {
528 count,
529 max: MAX_RETICLE_MARKS,
530 });
531 }
532 Ok(())
533}
534
535fn require_generated_size_checked(count: Option<usize>) -> Result<(), ReticleError> {
541 match count {
542 Some(c) => require_generated_size(c),
543 None => Err(ReticleError::TooManyMarks {
544 count: usize::MAX,
545 max: MAX_RETICLE_MARKS,
546 }),
547 }
548}
549
550pub fn hold_point_in_reticle(
569 drop_mil: f64,
570 wind_mil: f64,
571 magnification: f64,
572 reticle: &ReticleDescription,
573) -> Result<ReticleHold, ReticleError> {
574 reticle.validate()?;
575 require_positive_magnification(magnification)?;
576 if !drop_mil.is_finite() || !wind_mil.is_finite() {
577 return Err(ReticleError::NonFiniteHold { drop_mil, wind_mil });
578 }
579
580 let scale = reticle.mark_scale(magnification);
581 let scaled: Vec<ScaledMark> = reticle
582 .marks
583 .iter()
584 .map(|mark| ScaledMark {
585 down_mil: mark.down_mil * scale,
586 right_mil: mark.right_mil * scale,
587 })
588 .collect();
589
590 let mut nearest_index = 0usize;
593 let mut nearest_distance = f64::INFINITY;
594 for (index, mark) in scaled.iter().enumerate() {
595 let distance =
596 ((drop_mil - mark.down_mil).powi(2) + (wind_mil - mark.right_mil).powi(2)).sqrt();
597 if distance < nearest_distance {
598 nearest_distance = distance;
599 nearest_index = index;
600 }
601 }
602
603 let (down_lo, down_hi) = span(scaled.iter().map(|m| m.down_mil));
604 let (right_lo, right_hi) = span(scaled.iter().map(|m| m.right_mil));
605 let down_margin = OFF_RETICLE_MARGIN_FRACTION * (down_hi - down_lo);
606 let right_margin = OFF_RETICLE_MARGIN_FRACTION * (right_hi - right_lo);
607 let off_reticle = drop_mil < down_lo - down_margin
608 || drop_mil > down_hi + down_margin
609 || wind_mil < right_lo - right_margin
610 || wind_mil > right_hi + right_margin;
611
612 Ok(ReticleHold {
613 down_mil: drop_mil,
614 right_mil: wind_mil,
615 nearest_mark: Some(nearest_index),
616 nearest_mark_distance_mil: nearest_distance,
617 off_reticle,
618 mark_scale: scale,
619 })
620}
621
622fn span(values: impl Iterator<Item = f64>) -> (f64, f64) {
624 let mut lo = f64::INFINITY;
625 let mut hi = f64::NEG_INFINITY;
626 for value in values {
627 if value < lo {
628 lo = value;
629 }
630 if value > hi {
631 hi = value;
632 }
633 }
634 (lo, hi)
635}
636
637#[derive(Debug, Clone, Copy, PartialEq, Eq)]
639pub enum ReticleFormat {
640 Table,
641 Json,
642}
643
644pub fn format_reticle_hold(
653 hold: &ReticleHold,
654 reticle: &ReticleDescription,
655 magnification: f64,
656 format: ReticleFormat,
657) -> String {
658 let nearest = hold.nearest_mark.and_then(|index| reticle.marks.get(index));
659 match format {
660 ReticleFormat::Json => {
661 let mut value = serde_json::json!({
662 "reticle": reticle.name,
663 "focal_plane": reticle.focal_plane.label(),
664 "reference_magnification": reticle.reference_magnification,
665 "magnification": magnification,
666 "mark_scale": hold.mark_scale,
667 "hold": {
668 "down_mil": hold.down_mil,
669 "right_mil": hold.right_mil,
670 },
671 "off_reticle": hold.off_reticle,
672 "nearest_mark": serde_json::Value::Null,
673 });
674 if let (Some(index), Some(mark)) = (hold.nearest_mark, nearest) {
675 let scale = hold.mark_scale;
676 value["nearest_mark"] = serde_json::json!({
677 "index": index,
678 "kind": mark.kind.as_str(),
679 "label": mark.label,
680 "nominal_down_mil": mark.down_mil,
681 "nominal_right_mil": mark.right_mil,
682 "true_down_mil": mark.down_mil * scale,
683 "true_right_mil": mark.right_mil * scale,
684 "distance_mil": hold.nearest_mark_distance_mil,
685 });
686 }
687 format!(
688 "{}\n",
689 serde_json::to_string_pretty(&value)
690 .unwrap_or_else(|_| "{\"error\":\"serialization failed\"}".to_string())
691 )
692 }
693 ReticleFormat::Table => {
694 let mut out = String::new();
695 out.push_str("Reticle Hold Point\n");
696 out.push_str("==================\n\n");
697 out.push_str(&format!("Reticle: {}\n", reticle.name));
698 out.push_str(&format!(
699 "Focal plane: {}\n",
700 reticle.focal_plane.label()
701 ));
702 if reticle.focal_plane.is_magnification_dependent() {
703 out.push_str(&format!(
704 "Reference mag: {:.2}x\n",
705 reticle.reference_magnification
706 ));
707 out.push_str(&format!("Magnification: {magnification:.2}x\n"));
708 out.push_str(&format!(
709 "Subtension scale: {:.4}x (marks read {:.4}x their etched value)\n",
710 hold.mark_scale, hold.mark_scale
711 ));
712 } else {
713 out.push_str(&format!(
714 "Magnification: {magnification:.2}x (FFP: subtensions are magnification-independent)\n"
715 ));
716 }
717 out.push('\n');
718 out.push_str(&format!("Hold down: {:>8.3} mil\n", hold.down_mil));
719 out.push_str(&format!("Hold right: {:>8.3} mil\n", hold.right_mil));
720 out.push('\n');
721 match nearest {
722 Some(mark) => {
723 let scale = hold.mark_scale;
724 let label = mark.label.as_deref().unwrap_or("-");
725 out.push_str(&format!(
726 "Nearest mark: #{} {} ({})\n",
727 hold.nearest_mark.unwrap_or(0),
728 mark.kind.as_str(),
729 label
730 ));
731 out.push_str(&format!(
732 " at (down {:.3}, right {:.3}) mil true\n",
733 mark.down_mil * scale,
734 mark.right_mil * scale
735 ));
736 out.push_str(&format!(
737 " distance from hold: {:.3} mil\n",
738 hold.nearest_mark_distance_mil
739 ));
740 }
741 None => out.push_str("Nearest mark: none\n"),
742 }
743 if hold.off_reticle {
744 out.push_str(
745 "\nWARNING: the hold falls outside the marked area of this reticle \
746 (dial instead, or use a reticle with more holdover).\n",
747 );
748 }
749 out
750 }
751 }
752}
753
754pub fn format_reticle_description(reticle: &ReticleDescription, format: ReticleFormat) -> String {
759 match format {
760 ReticleFormat::Json => format!(
761 "{}\n",
762 serde_json::to_string_pretty(reticle)
763 .unwrap_or_else(|_| "{\"error\":\"serialization failed\"}".to_string())
764 ),
765 ReticleFormat::Table => {
766 let mut out = String::new();
767 out.push_str(&format!("Reticle: {}\n", reticle.name));
768 out.push_str(&format!("Focal plane: {}", reticle.focal_plane.label()));
769 if reticle.focal_plane.is_magnification_dependent() {
770 out.push_str(&format!(
771 " Reference magnification: {:.2}x",
772 reticle.reference_magnification
773 ));
774 }
775 out.push_str(&format!(" Marks: {}\n\n", reticle.marks.len()));
776 out.push_str(" # Kind Down(mil) Right(mil) Label\n");
777 out.push_str("---- ------- ---------- ----------- --------------------\n");
778 for (index, mark) in reticle.marks.iter().enumerate() {
779 out.push_str(&format!(
780 "{:>4} {:<7} {:>10.3} {:>11.3} {}\n",
781 index,
782 mark.kind.as_str(),
783 mark.down_mil,
784 mark.right_mil,
785 mark.label.as_deref().unwrap_or("-")
786 ));
787 }
788 out
789 }
790 }
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796
797 fn sfp_two_mil_at_ten() -> ReticleDescription {
798 ReticleDescription {
799 name: "test sfp".to_string(),
800 focal_plane: FocalPlane::Second,
801 reference_magnification: 10.0,
802 marks: vec![
803 ReticleMark::new(0.0, 0.0, MarkKind::Center),
804 ReticleMark::new(2.0, 0.0, MarkKind::Hash),
805 ReticleMark::new(4.0, 0.0, MarkKind::Hash),
806 ],
807 }
808 }
809
810 fn ffp_ladder() -> ReticleDescription {
811 ReticleDescription {
812 name: "test ffp".to_string(),
813 focal_plane: FocalPlane::First,
814 reference_magnification: 1.0,
815 marks: vec![
816 ReticleMark::new(0.0, 0.0, MarkKind::Center),
817 ReticleMark::new(2.0, 0.0, MarkKind::Hash),
818 ReticleMark::new(4.0, 0.0, MarkKind::Hash),
819 ReticleMark::new(2.0, 1.0, MarkKind::Dot),
820 ReticleMark::new(2.0, -1.0, MarkKind::Dot),
821 ],
822 }
823 }
824
825 #[test]
826 fn ffp_marks_are_invariant_across_magnification() {
827 let reticle = ffp_ladder();
828 let a = hold_point_in_reticle(2.3, 0.4, 4.0, &reticle).unwrap();
829 let b = hold_point_in_reticle(2.3, 0.4, 25.0, &reticle).unwrap();
830 assert_eq!(a, b, "FFP hold must not depend on magnification");
831 assert_eq!(a.mark_scale, 1.0);
832 }
833
834 #[test]
835 fn sfp_marks_scale_by_reference_over_current_magnification() {
836 let reticle = sfp_two_mil_at_ten();
837
838 let scaled = reticle.scaled_marks(10.0).unwrap();
840 assert_eq!(scaled[1].down_mil, 2.0);
841
842 let scaled = reticle.scaled_marks(5.0).unwrap();
844 assert_eq!(scaled[1].down_mil, 4.0);
845 assert_eq!(scaled[2].down_mil, 8.0);
846
847 let scaled = reticle.scaled_marks(20.0).unwrap();
849 assert_eq!(scaled[1].down_mil, 1.0);
850 }
851
852 #[test]
853 fn sfp_nearest_mark_is_measured_in_true_angular_space() {
854 let reticle = sfp_two_mil_at_ten();
855 let hold = hold_point_in_reticle(4.0, 0.0, 5.0, &reticle).unwrap();
858 assert_eq!(hold.nearest_mark, Some(1));
859 assert_eq!(hold.nearest_mark_distance_mil, 0.0);
860 assert_eq!(hold.down_mil, 4.0, "the hold stays TRUE angular");
861 assert_eq!(hold.mark_scale, 2.0);
862
863 let hold = hold_point_in_reticle(4.0, 0.0, 10.0, &reticle).unwrap();
865 assert_eq!(hold.nearest_mark, Some(2));
866 assert_eq!(hold.nearest_mark_distance_mil, 0.0);
867 }
868
869 #[test]
870 fn hold_on_a_mark_has_zero_distance() {
871 let reticle = ffp_ladder();
872 let hold = hold_point_in_reticle(2.0, 1.0, 10.0, &reticle).unwrap();
873 assert_eq!(hold.nearest_mark, Some(3));
874 assert_eq!(hold.nearest_mark_distance_mil, 0.0);
875 assert!(!hold.off_reticle);
876 }
877
878 #[test]
879 fn off_reticle_boundary_follows_the_documented_margin() {
880 let reticle = ffp_ladder();
881 let inside = hold_point_in_reticle(4.8, 0.0, 10.0, &reticle).unwrap();
883 assert!(
884 !inside.off_reticle,
885 "exactly on the margin counts as on-reticle"
886 );
887 let outside = hold_point_in_reticle(4.80001, 0.0, 10.0, &reticle).unwrap();
888 assert!(outside.off_reticle);
889
890 let inside = hold_point_in_reticle(2.0, 1.4, 10.0, &reticle).unwrap();
891 assert!(!inside.off_reticle);
892 let outside = hold_point_in_reticle(2.0, 1.40001, 10.0, &reticle).unwrap();
893 assert!(outside.off_reticle);
894
895 assert!(
897 hold_point_in_reticle(-0.9, 0.0, 10.0, &reticle)
898 .unwrap()
899 .off_reticle
900 );
901 }
902
903 #[test]
904 fn sfp_off_reticle_uses_the_scaled_bounding_box() {
905 let reticle = sfp_two_mil_at_ten();
906 assert!(
908 !hold_point_in_reticle(9.0, 0.0, 5.0, &reticle)
909 .unwrap()
910 .off_reticle
911 );
912 assert!(
914 hold_point_in_reticle(9.0, 0.0, 20.0, &reticle)
915 .unwrap()
916 .off_reticle
917 );
918 }
919
920 #[test]
921 fn rejects_non_physical_magnification_on_both_planes() {
922 for reticle in [ffp_ladder(), sfp_two_mil_at_ten()] {
923 assert_eq!(
924 hold_point_in_reticle(1.0, 0.0, 0.0, &reticle),
925 Err(ReticleError::NonPositiveMagnification { magnification: 0.0 })
926 );
927 assert!(matches!(
928 hold_point_in_reticle(1.0, 0.0, -3.0, &reticle),
929 Err(ReticleError::NonPositiveMagnification { .. })
930 ));
931 assert!(matches!(
932 hold_point_in_reticle(1.0, 0.0, f64::NAN, &reticle),
933 Err(ReticleError::NonPositiveMagnification { .. })
934 ));
935 }
936 }
937
938 #[test]
939 fn sfp_rejects_a_non_positive_reference_magnification_but_ffp_ignores_it() {
940 let mut sfp = sfp_two_mil_at_ten();
941 sfp.reference_magnification = 0.0;
942 assert!(matches!(
943 hold_point_in_reticle(1.0, 0.0, 10.0, &sfp),
944 Err(ReticleError::NonPositiveReferenceMagnification { .. })
945 ));
946
947 let mut ffp = ffp_ladder();
948 ffp.reference_magnification = 0.0;
949 assert!(
950 hold_point_in_reticle(1.0, 0.0, 10.0, &ffp).is_ok(),
951 "FFP never consults the reference magnification"
952 );
953 }
954
955 #[test]
956 fn rejects_empty_and_non_finite_descriptions() {
957 let mut reticle = ffp_ladder();
958 reticle.marks.clear();
959 assert_eq!(
960 hold_point_in_reticle(1.0, 0.0, 10.0, &reticle),
961 Err(ReticleError::NoMarks)
962 );
963
964 let mut reticle = ffp_ladder();
965 reticle.marks[2].down_mil = f64::NAN;
966 assert_eq!(
967 hold_point_in_reticle(1.0, 0.0, 10.0, &reticle),
968 Err(ReticleError::NonFiniteMark { index: 2 })
969 );
970
971 let reticle = ffp_ladder();
972 assert!(matches!(
973 hold_point_in_reticle(f64::NAN, 0.0, 10.0, &reticle),
974 Err(ReticleError::NonFiniteHold { .. })
975 ));
976 }
977
978 #[test]
979 fn mil_grid_generates_a_cross_not_a_filled_grid() {
980 let reticle = ReticleDescription::mil_grid(0.5, 2.0).unwrap();
981 assert_eq!(reticle.marks.len(), 1 + 4 * 4);
983 assert_eq!(reticle.marks[0].kind, MarkKind::Center);
984 for mark in &reticle.marks[1..] {
987 assert!(
988 mark.down_mil == 0.0 || mark.right_mil == 0.0,
989 "mil_grid must not produce off-axis marks"
990 );
991 }
992 assert_eq!(reticle.focal_plane, FocalPlane::First);
993 }
994
995 #[test]
996 fn tree_widens_one_step_per_row() {
997 let reticle = ReticleDescription::tree(3, 1.0, 0.5).unwrap();
998 assert_eq!(reticle.marks.len(), 1 + 3 + 2 * (1 + 2 + 3));
1000 let widest_in_row = |down: f64| {
1001 reticle
1002 .marks
1003 .iter()
1004 .filter(|m| m.down_mil == down)
1005 .map(|m| m.right_mil.abs())
1006 .fold(0.0_f64, f64::max)
1007 };
1008 assert_eq!(widest_in_row(1.0), 0.5);
1009 assert_eq!(widest_in_row(2.0), 1.0);
1010 assert_eq!(widest_in_row(3.0), 1.5);
1011 }
1012
1013 #[test]
1014 fn bdc_from_drops_is_pure_data_assembly() {
1015 let reticle =
1016 ReticleDescription::bdc_from_drops(&[(300.0, 1.2), (400.0, 2.4), (500.0, 4.1)])
1017 .unwrap();
1018 assert_eq!(reticle.marks.len(), 4);
1019 assert_eq!(reticle.marks[0].kind, MarkKind::Center);
1020 assert_eq!(reticle.marks[1].down_mil, 1.2);
1021 assert_eq!(reticle.marks[1].label.as_deref(), Some("300 m"));
1022 assert_eq!(reticle.marks[3].down_mil, 4.1);
1023 assert!(reticle.marks.iter().all(|m| m.right_mil == 0.0));
1025 }
1026
1027 #[test]
1028 fn generators_reject_bad_parameters() {
1029 assert!(ReticleDescription::mil_grid(0.0, 5.0).is_err());
1030 assert!(ReticleDescription::mil_grid(0.5, 0.0).is_err());
1031 assert!(ReticleDescription::mil_grid(5.0, 1.0).is_err());
1032 assert!(ReticleDescription::tree(0, 1.0, 0.5).is_err());
1033 assert!(ReticleDescription::tree(3, -1.0, 0.5).is_err());
1034 assert!(ReticleDescription::bdc_from_drops(&[]).is_err());
1035 assert!(ReticleDescription::bdc_from_drops(&[(0.0, 1.0)]).is_err());
1036 assert!(ReticleDescription::bdc_from_drops(&[(100.0, f64::NAN)]).is_err());
1037 assert!(matches!(
1039 ReticleDescription::mil_grid(0.001, 10.0),
1040 Err(ReticleError::TooManyMarks { .. })
1041 ));
1042 }
1043
1044 #[test]
1045 fn generator_sizes_cannot_overflow_past_the_cap() {
1046 let huge = (usize::MAX / 2) as f64; assert!(matches!(
1052 ReticleDescription::mil_grid(1.0, huge),
1053 Err(ReticleError::TooManyMarks { .. })
1054 ));
1055 assert!(matches!(
1056 ReticleDescription::tree(usize::MAX / 2, 1.0, 0.5),
1057 Err(ReticleError::TooManyMarks { .. })
1058 ));
1059 assert!(matches!(
1061 ReticleDescription::tree(1000, 1.0, 0.5),
1062 Err(ReticleError::TooManyMarks { .. })
1063 ));
1064 }
1065
1066 #[test]
1067 fn description_round_trips_through_serde() {
1068 let reticle = ReticleDescription {
1069 name: "round trip".to_string(),
1070 focal_plane: FocalPlane::Second,
1071 reference_magnification: 12.0,
1072 marks: vec![
1073 ReticleMark::new(0.0, 0.0, MarkKind::Center),
1074 ReticleMark::labeled(3.5, -1.5, MarkKind::Dot, "600 yd"),
1075 ReticleMark::new(6.0, 0.0, MarkKind::Post),
1076 ],
1077 };
1078 let json = serde_json::to_string(&reticle).unwrap();
1079 assert!(
1080 json.contains("\"sfp\""),
1081 "focal plane serializes as sfp: {json}"
1082 );
1083 assert!(json.contains("\"center\""));
1084 let back: ReticleDescription = serde_json::from_str(&json).unwrap();
1085 assert_eq!(back, reticle);
1086
1087 let json = serde_json::to_string(&ReticleMark::new(1.0, 0.0, MarkKind::Hash)).unwrap();
1089 assert!(!json.contains("label"), "{json}");
1090
1091 let permissive: ReticleDescription = serde_json::from_str(
1093 r#"{"name":"x","focal_plane":"ffp","reference_magnification":1.0,
1094 "marks":[{"down_mil":1.0,"right_mil":0.0,"kind":"hash"}],"stroke":"thin"}"#,
1095 )
1096 .unwrap();
1097 assert_eq!(permissive.marks.len(), 1);
1098 }
1099
1100 #[test]
1101 fn hold_round_trips_through_serde() {
1102 let reticle = ffp_ladder();
1103 let hold = hold_point_in_reticle(3.1, 0.6, 10.0, &reticle).unwrap();
1104 let back: ReticleHold =
1105 serde_json::from_str(&serde_json::to_string(&hold).unwrap()).unwrap();
1106 assert_eq!(back, hold);
1107 }
1108
1109 #[test]
1110 fn formatters_are_stable_and_json_is_the_schema() {
1111 let reticle = ReticleDescription::bdc_from_drops(&[(300.0, 1.2)]).unwrap();
1112 let json = format_reticle_description(&reticle, ReticleFormat::Json);
1113 assert!(json.ends_with('\n'));
1114 let back: ReticleDescription = serde_json::from_str(&json).unwrap();
1115 assert_eq!(back, reticle, "generate -o json feeds hold --reticle-json");
1116
1117 let hold = hold_point_in_reticle(1.2, 0.0, 10.0, &reticle).unwrap();
1118 let table = format_reticle_hold(&hold, &reticle, 10.0, ReticleFormat::Table);
1119 assert!(table.contains("Hold down:"));
1120 assert!(table.contains("300 m"));
1121 assert!(
1122 !table.contains("Subtension scale"),
1123 "FFP hides SFP-only rows"
1124 );
1125
1126 let sfp = sfp_two_mil_at_ten();
1127 let hold = hold_point_in_reticle(4.0, 0.0, 5.0, &sfp).unwrap();
1128 let table = format_reticle_hold(&hold, &sfp, 5.0, ReticleFormat::Table);
1129 assert!(table.contains("Subtension scale: 2.0000x"));
1130 }
1131}