1use std::error::Error;
60use std::fmt;
61
62use serde::{Deserialize, Serialize};
63
64pub const MAX_RETICLE_MARKS: usize = 4096;
72
73pub const OFF_RETICLE_MARGIN_FRACTION: f64 = 0.20;
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
82pub enum FocalPlane {
83 #[serde(rename = "ffp")]
85 #[default]
86 First,
87 #[serde(rename = "sfp")]
89 Second,
90}
91
92impl FocalPlane {
93 pub fn label(self) -> &'static str {
95 match self {
96 FocalPlane::First => "FFP",
97 FocalPlane::Second => "SFP",
98 }
99 }
100
101 pub fn is_magnification_dependent(self) -> bool {
103 matches!(self, FocalPlane::Second)
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
110#[serde(rename_all = "snake_case")]
111pub enum MarkKind {
112 #[default]
114 Dot,
115 Hash,
117 Post,
119 Center,
121}
122
123impl MarkKind {
124 pub fn as_str(self) -> &'static str {
126 match self {
127 MarkKind::Dot => "dot",
128 MarkKind::Hash => "hash",
129 MarkKind::Post => "post",
130 MarkKind::Center => "center",
131 }
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141pub struct ReticleMark {
142 pub down_mil: f64,
144 pub right_mil: f64,
146 #[serde(default)]
148 pub kind: MarkKind,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub label: Option<String>,
152}
153
154impl ReticleMark {
155 pub fn new(down_mil: f64, right_mil: f64, kind: MarkKind) -> Self {
157 Self {
158 down_mil,
159 right_mil,
160 kind,
161 label: None,
162 }
163 }
164
165 pub fn labeled(down_mil: f64, right_mil: f64, kind: MarkKind, label: impl Into<String>) -> Self {
167 Self {
168 down_mil,
169 right_mil,
170 kind,
171 label: Some(label.into()),
172 }
173 }
174}
175
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
182pub struct ReticleDescription {
183 pub name: String,
185 pub focal_plane: FocalPlane,
186 pub reference_magnification: f64,
190 pub marks: Vec<ReticleMark>,
191}
192
193#[derive(Debug, Clone, PartialEq)]
196pub enum ReticleError {
197 NonPositiveMagnification { magnification: f64 },
201 NonPositiveReferenceMagnification { reference_magnification: f64 },
205 NoMarks,
207 TooManyMarks { count: usize, max: usize },
209 NonFiniteMark { index: usize },
211 NonFiniteHold { drop_mil: f64, wind_mil: f64 },
213 InvalidGeneratorParameter {
215 parameter: &'static str,
216 value: f64,
217 rule: &'static str,
218 },
219}
220
221impl fmt::Display for ReticleError {
222 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223 match self {
224 ReticleError::NonPositiveMagnification { magnification } => write!(
225 f,
226 "magnification must be finite and greater than zero (got {magnification})"
227 ),
228 ReticleError::NonPositiveReferenceMagnification {
229 reference_magnification,
230 } => write!(
231 f,
232 "an SFP reticle's reference magnification must be finite and greater than \
233 zero (got {reference_magnification})"
234 ),
235 ReticleError::NoMarks => {
236 write!(f, "the reticle description carries no marks")
237 }
238 ReticleError::TooManyMarks { count, max } => write!(
239 f,
240 "the reticle description carries {count} marks, more than the supported maximum of {max}"
241 ),
242 ReticleError::NonFiniteMark { index } => write!(
243 f,
244 "reticle mark {index} has non-finite coordinates"
245 ),
246 ReticleError::NonFiniteHold { drop_mil, wind_mil } => write!(
247 f,
248 "the hold must be finite (got drop {drop_mil} mil, wind {wind_mil} mil)"
249 ),
250 ReticleError::InvalidGeneratorParameter {
251 parameter,
252 value,
253 rule,
254 } => write!(f, "{parameter} must be {rule} (got {value})"),
255 }
256 }
257}
258
259impl Error for ReticleError {}
260
261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263pub struct ReticleHold {
264 pub down_mil: f64,
267 pub right_mil: f64,
270 pub nearest_mark: Option<usize>,
274 pub nearest_mark_distance_mil: f64,
277 pub off_reticle: bool,
287 pub mark_scale: f64,
290}
291
292#[derive(Debug, Clone, Copy, PartialEq)]
294pub struct ScaledMark {
295 pub down_mil: f64,
296 pub right_mil: f64,
297}
298
299impl ReticleDescription {
300 pub fn validate(&self) -> Result<(), ReticleError> {
303 if self.marks.is_empty() {
304 return Err(ReticleError::NoMarks);
305 }
306 if self.marks.len() > MAX_RETICLE_MARKS {
307 return Err(ReticleError::TooManyMarks {
308 count: self.marks.len(),
309 max: MAX_RETICLE_MARKS,
310 });
311 }
312 for (index, mark) in self.marks.iter().enumerate() {
313 if !mark.down_mil.is_finite() || !mark.right_mil.is_finite() {
314 return Err(ReticleError::NonFiniteMark { index });
315 }
316 }
317 if self.focal_plane.is_magnification_dependent()
318 && (!self.reference_magnification.is_finite() || self.reference_magnification <= 0.0)
319 {
320 return Err(ReticleError::NonPositiveReferenceMagnification {
321 reference_magnification: self.reference_magnification,
322 });
323 }
324 Ok(())
325 }
326
327 pub fn mark_scale(&self, magnification: f64) -> f64 {
333 match self.focal_plane {
334 FocalPlane::First => 1.0,
335 FocalPlane::Second => self.reference_magnification / magnification,
336 }
337 }
338
339 pub fn scaled_marks(&self, magnification: f64) -> Result<Vec<ScaledMark>, ReticleError> {
341 self.validate()?;
342 require_positive_magnification(magnification)?;
343 let scale = self.mark_scale(magnification);
344 Ok(self
345 .marks
346 .iter()
347 .map(|mark| ScaledMark {
348 down_mil: mark.down_mil * scale,
349 right_mil: mark.right_mil * scale,
350 })
351 .collect())
352 }
353
354 pub fn mil_grid(spacing_mil: f64, extent_mil: f64) -> Result<Self, ReticleError> {
361 require_generator_positive("spacing", spacing_mil)?;
362 require_generator_positive("extent", extent_mil)?;
363 if extent_mil < spacing_mil {
364 return Err(ReticleError::InvalidGeneratorParameter {
365 parameter: "extent",
366 value: extent_mil,
367 rule: "greater than or equal to the spacing",
368 });
369 }
370 let steps = (extent_mil / spacing_mil).floor() as usize;
371 require_generated_size_checked(4usize.checked_mul(steps).and_then(|v| v.checked_add(1)))?;
374
375 let mut marks = Vec::with_capacity(1 + 4 * steps);
376 marks.push(ReticleMark::new(0.0, 0.0, MarkKind::Center));
377 for step in 1..=steps {
378 let offset = spacing_mil * step as f64;
379 marks.push(ReticleMark::new(offset, 0.0, MarkKind::Hash));
381 marks.push(ReticleMark::new(-offset, 0.0, MarkKind::Hash));
382 marks.push(ReticleMark::new(0.0, offset, MarkKind::Hash));
384 marks.push(ReticleMark::new(0.0, -offset, MarkKind::Hash));
385 }
386 Ok(Self {
387 name: format!("mil-cross {spacing_mil}/{extent_mil}"),
388 focal_plane: FocalPlane::First,
389 reference_magnification: 1.0,
390 marks,
391 })
392 }
393
394 pub fn tree(
400 rows: usize,
401 row_spacing_mil: f64,
402 spread_step_mil: f64,
403 ) -> Result<Self, ReticleError> {
404 if rows == 0 {
405 return Err(ReticleError::InvalidGeneratorParameter {
406 parameter: "rows",
407 value: 0.0,
408 rule: "at least 1",
409 });
410 }
411 require_generator_positive("row-spacing", row_spacing_mil)?;
412 require_generator_positive("spread-step", spread_step_mil)?;
413 require_generated_size_checked(
416 rows.checked_add(1)
417 .and_then(|r1| rows.checked_mul(r1))
418 .and_then(|p| p.checked_add(rows))
419 .and_then(|p| p.checked_add(1)),
420 )?;
421
422 let mut marks = Vec::new();
423 marks.push(ReticleMark::new(0.0, 0.0, MarkKind::Center));
424 for row in 1..=rows {
425 let down = row_spacing_mil * row as f64;
426 marks.push(ReticleMark::new(down, 0.0, MarkKind::Hash));
427 for step in 1..=row {
428 let spread = spread_step_mil * step as f64;
429 marks.push(ReticleMark::new(down, spread, MarkKind::Dot));
430 marks.push(ReticleMark::new(down, -spread, MarkKind::Dot));
431 }
432 }
433 Ok(Self {
434 name: format!("tree {rows}x{row_spacing_mil}/{spread_step_mil}"),
435 focal_plane: FocalPlane::First,
436 reference_magnification: 1.0,
437 marks,
438 })
439 }
440
441 pub fn bdc_from_drops(drops: &[(f64, f64)]) -> Result<Self, ReticleError> {
448 if drops.is_empty() {
449 return Err(ReticleError::InvalidGeneratorParameter {
450 parameter: "drops",
451 value: 0.0,
452 rule: "a non-empty list of (range, drop) pairs",
453 });
454 }
455 require_generated_size(1 + drops.len())?;
456 for &(range_m, drop_mil) in drops {
457 if !range_m.is_finite() || range_m <= 0.0 {
458 return Err(ReticleError::InvalidGeneratorParameter {
459 parameter: "drop range",
460 value: range_m,
461 rule: "finite and greater than zero",
462 });
463 }
464 if !drop_mil.is_finite() {
465 return Err(ReticleError::InvalidGeneratorParameter {
466 parameter: "drop",
467 value: drop_mil,
468 rule: "finite",
469 });
470 }
471 }
472
473 let mut marks = Vec::with_capacity(1 + drops.len());
474 marks.push(ReticleMark::new(0.0, 0.0, MarkKind::Center));
475 for &(range_m, drop_mil) in drops {
476 marks.push(ReticleMark::labeled(
477 drop_mil,
478 0.0,
479 MarkKind::Hash,
480 format!("{range_m} m"),
481 ));
482 }
483 Ok(Self {
484 name: format!("bdc {} marks", drops.len()),
485 focal_plane: FocalPlane::First,
486 reference_magnification: 1.0,
487 marks,
488 })
489 }
490}
491
492fn require_positive_magnification(magnification: f64) -> Result<(), ReticleError> {
493 if !magnification.is_finite() || magnification <= 0.0 {
494 return Err(ReticleError::NonPositiveMagnification { magnification });
495 }
496 Ok(())
497}
498
499fn require_generator_positive(parameter: &'static str, value: f64) -> Result<(), ReticleError> {
500 if !value.is_finite() || value <= 0.0 {
501 return Err(ReticleError::InvalidGeneratorParameter {
502 parameter,
503 value,
504 rule: "finite and greater than zero",
505 });
506 }
507 Ok(())
508}
509
510fn require_generated_size(count: usize) -> Result<(), ReticleError> {
511 if count > MAX_RETICLE_MARKS {
512 return Err(ReticleError::TooManyMarks {
513 count,
514 max: MAX_RETICLE_MARKS,
515 });
516 }
517 Ok(())
518}
519
520fn require_generated_size_checked(count: Option<usize>) -> Result<(), ReticleError> {
526 match count {
527 Some(c) => require_generated_size(c),
528 None => Err(ReticleError::TooManyMarks {
529 count: usize::MAX,
530 max: MAX_RETICLE_MARKS,
531 }),
532 }
533}
534
535pub fn hold_point_in_reticle(
554 drop_mil: f64,
555 wind_mil: f64,
556 magnification: f64,
557 reticle: &ReticleDescription,
558) -> Result<ReticleHold, ReticleError> {
559 reticle.validate()?;
560 require_positive_magnification(magnification)?;
561 if !drop_mil.is_finite() || !wind_mil.is_finite() {
562 return Err(ReticleError::NonFiniteHold { drop_mil, wind_mil });
563 }
564
565 let scale = reticle.mark_scale(magnification);
566 let scaled: Vec<ScaledMark> = reticle
567 .marks
568 .iter()
569 .map(|mark| ScaledMark {
570 down_mil: mark.down_mil * scale,
571 right_mil: mark.right_mil * scale,
572 })
573 .collect();
574
575 let mut nearest_index = 0usize;
578 let mut nearest_distance = f64::INFINITY;
579 for (index, mark) in scaled.iter().enumerate() {
580 let distance = ((drop_mil - mark.down_mil).powi(2) + (wind_mil - mark.right_mil).powi(2))
581 .sqrt();
582 if distance < nearest_distance {
583 nearest_distance = distance;
584 nearest_index = index;
585 }
586 }
587
588 let (down_lo, down_hi) = span(scaled.iter().map(|m| m.down_mil));
589 let (right_lo, right_hi) = span(scaled.iter().map(|m| m.right_mil));
590 let down_margin = OFF_RETICLE_MARGIN_FRACTION * (down_hi - down_lo);
591 let right_margin = OFF_RETICLE_MARGIN_FRACTION * (right_hi - right_lo);
592 let off_reticle = drop_mil < down_lo - down_margin
593 || drop_mil > down_hi + down_margin
594 || wind_mil < right_lo - right_margin
595 || wind_mil > right_hi + right_margin;
596
597 Ok(ReticleHold {
598 down_mil: drop_mil,
599 right_mil: wind_mil,
600 nearest_mark: Some(nearest_index),
601 nearest_mark_distance_mil: nearest_distance,
602 off_reticle,
603 mark_scale: scale,
604 })
605}
606
607fn span(values: impl Iterator<Item = f64>) -> (f64, f64) {
609 let mut lo = f64::INFINITY;
610 let mut hi = f64::NEG_INFINITY;
611 for value in values {
612 if value < lo {
613 lo = value;
614 }
615 if value > hi {
616 hi = value;
617 }
618 }
619 (lo, hi)
620}
621
622#[derive(Debug, Clone, Copy, PartialEq, Eq)]
624pub enum ReticleFormat {
625 Table,
626 Json,
627}
628
629pub fn format_reticle_hold(
638 hold: &ReticleHold,
639 reticle: &ReticleDescription,
640 magnification: f64,
641 format: ReticleFormat,
642) -> String {
643 let nearest = hold.nearest_mark.and_then(|index| reticle.marks.get(index));
644 match format {
645 ReticleFormat::Json => {
646 let mut value = serde_json::json!({
647 "reticle": reticle.name,
648 "focal_plane": reticle.focal_plane.label(),
649 "reference_magnification": reticle.reference_magnification,
650 "magnification": magnification,
651 "mark_scale": hold.mark_scale,
652 "hold": {
653 "down_mil": hold.down_mil,
654 "right_mil": hold.right_mil,
655 },
656 "off_reticle": hold.off_reticle,
657 "nearest_mark": serde_json::Value::Null,
658 });
659 if let (Some(index), Some(mark)) = (hold.nearest_mark, nearest) {
660 let scale = hold.mark_scale;
661 value["nearest_mark"] = serde_json::json!({
662 "index": index,
663 "kind": mark.kind.as_str(),
664 "label": mark.label,
665 "nominal_down_mil": mark.down_mil,
666 "nominal_right_mil": mark.right_mil,
667 "true_down_mil": mark.down_mil * scale,
668 "true_right_mil": mark.right_mil * scale,
669 "distance_mil": hold.nearest_mark_distance_mil,
670 });
671 }
672 format!(
673 "{}\n",
674 serde_json::to_string_pretty(&value)
675 .unwrap_or_else(|_| "{\"error\":\"serialization failed\"}".to_string())
676 )
677 }
678 ReticleFormat::Table => {
679 let mut out = String::new();
680 out.push_str("Reticle Hold Point\n");
681 out.push_str("==================\n\n");
682 out.push_str(&format!("Reticle: {}\n", reticle.name));
683 out.push_str(&format!(
684 "Focal plane: {}\n",
685 reticle.focal_plane.label()
686 ));
687 if reticle.focal_plane.is_magnification_dependent() {
688 out.push_str(&format!(
689 "Reference mag: {:.2}x\n",
690 reticle.reference_magnification
691 ));
692 out.push_str(&format!("Magnification: {magnification:.2}x\n"));
693 out.push_str(&format!(
694 "Subtension scale: {:.4}x (marks read {:.4}x their etched value)\n",
695 hold.mark_scale, hold.mark_scale
696 ));
697 } else {
698 out.push_str(&format!(
699 "Magnification: {magnification:.2}x (FFP: subtensions are magnification-independent)\n"
700 ));
701 }
702 out.push('\n');
703 out.push_str(&format!("Hold down: {:>8.3} mil\n", hold.down_mil));
704 out.push_str(&format!("Hold right: {:>8.3} mil\n", hold.right_mil));
705 out.push('\n');
706 match nearest {
707 Some(mark) => {
708 let scale = hold.mark_scale;
709 let label = mark.label.as_deref().unwrap_or("-");
710 out.push_str(&format!(
711 "Nearest mark: #{} {} ({})\n",
712 hold.nearest_mark.unwrap_or(0),
713 mark.kind.as_str(),
714 label
715 ));
716 out.push_str(&format!(
717 " at (down {:.3}, right {:.3}) mil true\n",
718 mark.down_mil * scale,
719 mark.right_mil * scale
720 ));
721 out.push_str(&format!(
722 " distance from hold: {:.3} mil\n",
723 hold.nearest_mark_distance_mil
724 ));
725 }
726 None => out.push_str("Nearest mark: none\n"),
727 }
728 if hold.off_reticle {
729 out.push_str(
730 "\nWARNING: the hold falls outside the marked area of this reticle \
731 (dial instead, or use a reticle with more holdover).\n",
732 );
733 }
734 out
735 }
736 }
737}
738
739pub fn format_reticle_description(reticle: &ReticleDescription, format: ReticleFormat) -> String {
744 match format {
745 ReticleFormat::Json => format!(
746 "{}\n",
747 serde_json::to_string_pretty(reticle)
748 .unwrap_or_else(|_| "{\"error\":\"serialization failed\"}".to_string())
749 ),
750 ReticleFormat::Table => {
751 let mut out = String::new();
752 out.push_str(&format!("Reticle: {}\n", reticle.name));
753 out.push_str(&format!(
754 "Focal plane: {}",
755 reticle.focal_plane.label()
756 ));
757 if reticle.focal_plane.is_magnification_dependent() {
758 out.push_str(&format!(
759 " Reference magnification: {:.2}x",
760 reticle.reference_magnification
761 ));
762 }
763 out.push_str(&format!(" Marks: {}\n\n", reticle.marks.len()));
764 out.push_str(" # Kind Down(mil) Right(mil) Label\n");
765 out.push_str("---- ------- ---------- ----------- --------------------\n");
766 for (index, mark) in reticle.marks.iter().enumerate() {
767 out.push_str(&format!(
768 "{:>4} {:<7} {:>10.3} {:>11.3} {}\n",
769 index,
770 mark.kind.as_str(),
771 mark.down_mil,
772 mark.right_mil,
773 mark.label.as_deref().unwrap_or("-")
774 ));
775 }
776 out
777 }
778 }
779}
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784
785 fn sfp_two_mil_at_ten() -> ReticleDescription {
786 ReticleDescription {
787 name: "test sfp".to_string(),
788 focal_plane: FocalPlane::Second,
789 reference_magnification: 10.0,
790 marks: vec![
791 ReticleMark::new(0.0, 0.0, MarkKind::Center),
792 ReticleMark::new(2.0, 0.0, MarkKind::Hash),
793 ReticleMark::new(4.0, 0.0, MarkKind::Hash),
794 ],
795 }
796 }
797
798 fn ffp_ladder() -> ReticleDescription {
799 ReticleDescription {
800 name: "test ffp".to_string(),
801 focal_plane: FocalPlane::First,
802 reference_magnification: 1.0,
803 marks: vec![
804 ReticleMark::new(0.0, 0.0, MarkKind::Center),
805 ReticleMark::new(2.0, 0.0, MarkKind::Hash),
806 ReticleMark::new(4.0, 0.0, MarkKind::Hash),
807 ReticleMark::new(2.0, 1.0, MarkKind::Dot),
808 ReticleMark::new(2.0, -1.0, MarkKind::Dot),
809 ],
810 }
811 }
812
813 #[test]
814 fn ffp_marks_are_invariant_across_magnification() {
815 let reticle = ffp_ladder();
816 let a = hold_point_in_reticle(2.3, 0.4, 4.0, &reticle).unwrap();
817 let b = hold_point_in_reticle(2.3, 0.4, 25.0, &reticle).unwrap();
818 assert_eq!(a, b, "FFP hold must not depend on magnification");
819 assert_eq!(a.mark_scale, 1.0);
820 }
821
822 #[test]
823 fn sfp_marks_scale_by_reference_over_current_magnification() {
824 let reticle = sfp_two_mil_at_ten();
825
826 let scaled = reticle.scaled_marks(10.0).unwrap();
828 assert_eq!(scaled[1].down_mil, 2.0);
829
830 let scaled = reticle.scaled_marks(5.0).unwrap();
832 assert_eq!(scaled[1].down_mil, 4.0);
833 assert_eq!(scaled[2].down_mil, 8.0);
834
835 let scaled = reticle.scaled_marks(20.0).unwrap();
837 assert_eq!(scaled[1].down_mil, 1.0);
838 }
839
840 #[test]
841 fn sfp_nearest_mark_is_measured_in_true_angular_space() {
842 let reticle = sfp_two_mil_at_ten();
843 let hold = hold_point_in_reticle(4.0, 0.0, 5.0, &reticle).unwrap();
846 assert_eq!(hold.nearest_mark, Some(1));
847 assert_eq!(hold.nearest_mark_distance_mil, 0.0);
848 assert_eq!(hold.down_mil, 4.0, "the hold stays TRUE angular");
849 assert_eq!(hold.mark_scale, 2.0);
850
851 let hold = hold_point_in_reticle(4.0, 0.0, 10.0, &reticle).unwrap();
853 assert_eq!(hold.nearest_mark, Some(2));
854 assert_eq!(hold.nearest_mark_distance_mil, 0.0);
855 }
856
857 #[test]
858 fn hold_on_a_mark_has_zero_distance() {
859 let reticle = ffp_ladder();
860 let hold = hold_point_in_reticle(2.0, 1.0, 10.0, &reticle).unwrap();
861 assert_eq!(hold.nearest_mark, Some(3));
862 assert_eq!(hold.nearest_mark_distance_mil, 0.0);
863 assert!(!hold.off_reticle);
864 }
865
866 #[test]
867 fn off_reticle_boundary_follows_the_documented_margin() {
868 let reticle = ffp_ladder();
869 let inside = hold_point_in_reticle(4.8, 0.0, 10.0, &reticle).unwrap();
871 assert!(!inside.off_reticle, "exactly on the margin counts as on-reticle");
872 let outside = hold_point_in_reticle(4.80001, 0.0, 10.0, &reticle).unwrap();
873 assert!(outside.off_reticle);
874
875 let inside = hold_point_in_reticle(2.0, 1.4, 10.0, &reticle).unwrap();
876 assert!(!inside.off_reticle);
877 let outside = hold_point_in_reticle(2.0, 1.40001, 10.0, &reticle).unwrap();
878 assert!(outside.off_reticle);
879
880 assert!(hold_point_in_reticle(-0.9, 0.0, 10.0, &reticle).unwrap().off_reticle);
882 }
883
884 #[test]
885 fn sfp_off_reticle_uses_the_scaled_bounding_box() {
886 let reticle = sfp_two_mil_at_ten();
887 assert!(!hold_point_in_reticle(9.0, 0.0, 5.0, &reticle).unwrap().off_reticle);
889 assert!(hold_point_in_reticle(9.0, 0.0, 20.0, &reticle).unwrap().off_reticle);
891 }
892
893 #[test]
894 fn rejects_non_physical_magnification_on_both_planes() {
895 for reticle in [ffp_ladder(), sfp_two_mil_at_ten()] {
896 assert_eq!(
897 hold_point_in_reticle(1.0, 0.0, 0.0, &reticle),
898 Err(ReticleError::NonPositiveMagnification { magnification: 0.0 })
899 );
900 assert!(matches!(
901 hold_point_in_reticle(1.0, 0.0, -3.0, &reticle),
902 Err(ReticleError::NonPositiveMagnification { .. })
903 ));
904 assert!(matches!(
905 hold_point_in_reticle(1.0, 0.0, f64::NAN, &reticle),
906 Err(ReticleError::NonPositiveMagnification { .. })
907 ));
908 }
909 }
910
911 #[test]
912 fn sfp_rejects_a_non_positive_reference_magnification_but_ffp_ignores_it() {
913 let mut sfp = sfp_two_mil_at_ten();
914 sfp.reference_magnification = 0.0;
915 assert!(matches!(
916 hold_point_in_reticle(1.0, 0.0, 10.0, &sfp),
917 Err(ReticleError::NonPositiveReferenceMagnification { .. })
918 ));
919
920 let mut ffp = ffp_ladder();
921 ffp.reference_magnification = 0.0;
922 assert!(
923 hold_point_in_reticle(1.0, 0.0, 10.0, &ffp).is_ok(),
924 "FFP never consults the reference magnification"
925 );
926 }
927
928 #[test]
929 fn rejects_empty_and_non_finite_descriptions() {
930 let mut reticle = ffp_ladder();
931 reticle.marks.clear();
932 assert_eq!(
933 hold_point_in_reticle(1.0, 0.0, 10.0, &reticle),
934 Err(ReticleError::NoMarks)
935 );
936
937 let mut reticle = ffp_ladder();
938 reticle.marks[2].down_mil = f64::NAN;
939 assert_eq!(
940 hold_point_in_reticle(1.0, 0.0, 10.0, &reticle),
941 Err(ReticleError::NonFiniteMark { index: 2 })
942 );
943
944 let reticle = ffp_ladder();
945 assert!(matches!(
946 hold_point_in_reticle(f64::NAN, 0.0, 10.0, &reticle),
947 Err(ReticleError::NonFiniteHold { .. })
948 ));
949 }
950
951 #[test]
952 fn mil_grid_generates_a_cross_not_a_filled_grid() {
953 let reticle = ReticleDescription::mil_grid(0.5, 2.0).unwrap();
954 assert_eq!(reticle.marks.len(), 1 + 4 * 4);
956 assert_eq!(reticle.marks[0].kind, MarkKind::Center);
957 for mark in &reticle.marks[1..] {
960 assert!(
961 mark.down_mil == 0.0 || mark.right_mil == 0.0,
962 "mil_grid must not produce off-axis marks"
963 );
964 }
965 assert_eq!(reticle.focal_plane, FocalPlane::First);
966 }
967
968 #[test]
969 fn tree_widens_one_step_per_row() {
970 let reticle = ReticleDescription::tree(3, 1.0, 0.5).unwrap();
971 assert_eq!(reticle.marks.len(), 1 + 3 + 2 * (1 + 2 + 3));
973 let widest_in_row = |down: f64| {
974 reticle
975 .marks
976 .iter()
977 .filter(|m| m.down_mil == down)
978 .map(|m| m.right_mil.abs())
979 .fold(0.0_f64, f64::max)
980 };
981 assert_eq!(widest_in_row(1.0), 0.5);
982 assert_eq!(widest_in_row(2.0), 1.0);
983 assert_eq!(widest_in_row(3.0), 1.5);
984 }
985
986 #[test]
987 fn bdc_from_drops_is_pure_data_assembly() {
988 let reticle =
989 ReticleDescription::bdc_from_drops(&[(300.0, 1.2), (400.0, 2.4), (500.0, 4.1)]).unwrap();
990 assert_eq!(reticle.marks.len(), 4);
991 assert_eq!(reticle.marks[0].kind, MarkKind::Center);
992 assert_eq!(reticle.marks[1].down_mil, 1.2);
993 assert_eq!(reticle.marks[1].label.as_deref(), Some("300 m"));
994 assert_eq!(reticle.marks[3].down_mil, 4.1);
995 assert!(reticle.marks.iter().all(|m| m.right_mil == 0.0));
997 }
998
999 #[test]
1000 fn generators_reject_bad_parameters() {
1001 assert!(ReticleDescription::mil_grid(0.0, 5.0).is_err());
1002 assert!(ReticleDescription::mil_grid(0.5, 0.0).is_err());
1003 assert!(ReticleDescription::mil_grid(5.0, 1.0).is_err());
1004 assert!(ReticleDescription::tree(0, 1.0, 0.5).is_err());
1005 assert!(ReticleDescription::tree(3, -1.0, 0.5).is_err());
1006 assert!(ReticleDescription::bdc_from_drops(&[]).is_err());
1007 assert!(ReticleDescription::bdc_from_drops(&[(0.0, 1.0)]).is_err());
1008 assert!(ReticleDescription::bdc_from_drops(&[(100.0, f64::NAN)]).is_err());
1009 assert!(matches!(
1011 ReticleDescription::mil_grid(0.001, 10.0),
1012 Err(ReticleError::TooManyMarks { .. })
1013 ));
1014 }
1015
1016 #[test]
1017 fn generator_sizes_cannot_overflow_past_the_cap() {
1018 let huge = (usize::MAX / 2) as f64; assert!(matches!(
1024 ReticleDescription::mil_grid(1.0, huge),
1025 Err(ReticleError::TooManyMarks { .. })
1026 ));
1027 assert!(matches!(
1028 ReticleDescription::tree(usize::MAX / 2, 1.0, 0.5),
1029 Err(ReticleError::TooManyMarks { .. })
1030 ));
1031 assert!(matches!(
1033 ReticleDescription::tree(1000, 1.0, 0.5),
1034 Err(ReticleError::TooManyMarks { .. })
1035 ));
1036 }
1037
1038 #[test]
1039 fn description_round_trips_through_serde() {
1040 let reticle = ReticleDescription {
1041 name: "round trip".to_string(),
1042 focal_plane: FocalPlane::Second,
1043 reference_magnification: 12.0,
1044 marks: vec![
1045 ReticleMark::new(0.0, 0.0, MarkKind::Center),
1046 ReticleMark::labeled(3.5, -1.5, MarkKind::Dot, "600 yd"),
1047 ReticleMark::new(6.0, 0.0, MarkKind::Post),
1048 ],
1049 };
1050 let json = serde_json::to_string(&reticle).unwrap();
1051 assert!(json.contains("\"sfp\""), "focal plane serializes as sfp: {json}");
1052 assert!(json.contains("\"center\""));
1053 let back: ReticleDescription = serde_json::from_str(&json).unwrap();
1054 assert_eq!(back, reticle);
1055
1056 let json = serde_json::to_string(&ReticleMark::new(1.0, 0.0, MarkKind::Hash)).unwrap();
1058 assert!(!json.contains("label"), "{json}");
1059
1060 let permissive: ReticleDescription = serde_json::from_str(
1062 r#"{"name":"x","focal_plane":"ffp","reference_magnification":1.0,
1063 "marks":[{"down_mil":1.0,"right_mil":0.0,"kind":"hash"}],"stroke":"thin"}"#,
1064 )
1065 .unwrap();
1066 assert_eq!(permissive.marks.len(), 1);
1067 }
1068
1069 #[test]
1070 fn hold_round_trips_through_serde() {
1071 let reticle = ffp_ladder();
1072 let hold = hold_point_in_reticle(3.1, 0.6, 10.0, &reticle).unwrap();
1073 let back: ReticleHold = serde_json::from_str(&serde_json::to_string(&hold).unwrap()).unwrap();
1074 assert_eq!(back, hold);
1075 }
1076
1077 #[test]
1078 fn formatters_are_stable_and_json_is_the_schema() {
1079 let reticle = ReticleDescription::bdc_from_drops(&[(300.0, 1.2)]).unwrap();
1080 let json = format_reticle_description(&reticle, ReticleFormat::Json);
1081 assert!(json.ends_with('\n'));
1082 let back: ReticleDescription = serde_json::from_str(&json).unwrap();
1083 assert_eq!(back, reticle, "generate -o json feeds hold --reticle-json");
1084
1085 let hold = hold_point_in_reticle(1.2, 0.0, 10.0, &reticle).unwrap();
1086 let table = format_reticle_hold(&hold, &reticle, 10.0, ReticleFormat::Table);
1087 assert!(table.contains("Hold down:"));
1088 assert!(table.contains("300 m"));
1089 assert!(!table.contains("Subtension scale"), "FFP hides SFP-only rows");
1090
1091 let sfp = sfp_two_mil_at_ten();
1092 let hold = hold_point_in_reticle(4.0, 0.0, 5.0, &sfp).unwrap();
1093 let table = format_reticle_hold(&hold, &sfp, 5.0, ReticleFormat::Table);
1094 assert!(table.contains("Subtension scale: 2.0000x"));
1095 }
1096}