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 InvalidSpec(String),
223}
224
225impl fmt::Display for ReticleError {
226 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227 match self {
228 ReticleError::NonPositiveMagnification { magnification } => write!(
229 f,
230 "magnification must be finite and greater than zero (got {magnification})"
231 ),
232 ReticleError::NonPositiveReferenceMagnification {
233 reference_magnification,
234 } => write!(
235 f,
236 "an SFP reticle's reference magnification must be finite and greater than \
237 zero (got {reference_magnification})"
238 ),
239 ReticleError::NoMarks => {
240 write!(f, "the reticle description carries no marks")
241 }
242 ReticleError::TooManyMarks { count, max } => write!(
243 f,
244 "the reticle description carries {count} marks, more than the supported maximum of {max}"
245 ),
246 ReticleError::NonFiniteMark { index } => write!(
247 f,
248 "reticle mark {index} has non-finite coordinates"
249 ),
250 ReticleError::NonFiniteHold { drop_mil, wind_mil } => write!(
251 f,
252 "the hold must be finite (got drop {drop_mil} mil, wind {wind_mil} mil)"
253 ),
254 ReticleError::InvalidGeneratorParameter {
255 parameter,
256 value,
257 rule,
258 } => write!(f, "{parameter} must be {rule} (got {value})"),
259 ReticleError::InvalidSpec(reason) => {
260 write!(f, "invalid reticle specification: {reason}")
261 }
262 }
263 }
264}
265
266impl Error for ReticleError {}
267
268#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
270pub struct ReticleHold {
271 pub down_mil: f64,
274 pub right_mil: f64,
277 pub nearest_mark: Option<usize>,
281 pub nearest_mark_distance_mil: f64,
284 pub off_reticle: bool,
294 pub mark_scale: f64,
297}
298
299#[derive(Debug, Clone, Copy, PartialEq)]
301pub struct ScaledMark {
302 pub down_mil: f64,
303 pub right_mil: f64,
304}
305
306impl ReticleDescription {
307 pub fn validate(&self) -> Result<(), ReticleError> {
310 if self.marks.is_empty() {
311 return Err(ReticleError::NoMarks);
312 }
313 if self.marks.len() > MAX_RETICLE_MARKS {
314 return Err(ReticleError::TooManyMarks {
315 count: self.marks.len(),
316 max: MAX_RETICLE_MARKS,
317 });
318 }
319 for (index, mark) in self.marks.iter().enumerate() {
320 if !mark.down_mil.is_finite() || !mark.right_mil.is_finite() {
321 return Err(ReticleError::NonFiniteMark { index });
322 }
323 }
324 if self.focal_plane.is_magnification_dependent()
325 && (!self.reference_magnification.is_finite() || self.reference_magnification <= 0.0)
326 {
327 return Err(ReticleError::NonPositiveReferenceMagnification {
328 reference_magnification: self.reference_magnification,
329 });
330 }
331 Ok(())
332 }
333
334 pub fn mark_scale(&self, magnification: f64) -> f64 {
340 match self.focal_plane {
341 FocalPlane::First => 1.0,
342 FocalPlane::Second => self.reference_magnification / magnification,
343 }
344 }
345
346 pub fn scaled_marks(&self, magnification: f64) -> Result<Vec<ScaledMark>, ReticleError> {
348 self.validate()?;
349 require_positive_magnification(magnification)?;
350 let scale = self.mark_scale(magnification);
351 Ok(self
352 .marks
353 .iter()
354 .map(|mark| ScaledMark {
355 down_mil: mark.down_mil * scale,
356 right_mil: mark.right_mil * scale,
357 })
358 .collect())
359 }
360
361 pub fn mil_grid(spacing_mil: f64, extent_mil: f64) -> Result<Self, ReticleError> {
368 require_generator_positive("spacing", spacing_mil)?;
369 require_generator_positive("extent", extent_mil)?;
370 if extent_mil < spacing_mil {
371 return Err(ReticleError::InvalidGeneratorParameter {
372 parameter: "extent",
373 value: extent_mil,
374 rule: "greater than or equal to the spacing",
375 });
376 }
377 let steps = (extent_mil / spacing_mil).floor() as usize;
378 require_generated_size_checked(4usize.checked_mul(steps).and_then(|v| v.checked_add(1)))?;
381
382 let mut marks = Vec::with_capacity(1 + 4 * steps);
383 marks.push(ReticleMark::new(0.0, 0.0, MarkKind::Center));
384 for step in 1..=steps {
385 let offset = spacing_mil * step as f64;
386 marks.push(ReticleMark::new(offset, 0.0, MarkKind::Hash));
388 marks.push(ReticleMark::new(-offset, 0.0, MarkKind::Hash));
389 marks.push(ReticleMark::new(0.0, offset, MarkKind::Hash));
391 marks.push(ReticleMark::new(0.0, -offset, MarkKind::Hash));
392 }
393 Ok(Self {
394 name: format!("mil-cross {spacing_mil}/{extent_mil}"),
395 focal_plane: FocalPlane::First,
396 reference_magnification: 1.0,
397 marks,
398 })
399 }
400
401 pub fn tree(
407 rows: usize,
408 row_spacing_mil: f64,
409 spread_step_mil: f64,
410 ) -> Result<Self, ReticleError> {
411 if rows == 0 {
412 return Err(ReticleError::InvalidGeneratorParameter {
413 parameter: "rows",
414 value: 0.0,
415 rule: "at least 1",
416 });
417 }
418 require_generator_positive("row-spacing", row_spacing_mil)?;
419 require_generator_positive("spread-step", spread_step_mil)?;
420 require_generated_size_checked(
423 rows.checked_add(1)
424 .and_then(|r1| rows.checked_mul(r1))
425 .and_then(|p| p.checked_add(rows))
426 .and_then(|p| p.checked_add(1)),
427 )?;
428
429 let mut marks = Vec::new();
430 marks.push(ReticleMark::new(0.0, 0.0, MarkKind::Center));
431 for row in 1..=rows {
432 let down = row_spacing_mil * row as f64;
433 marks.push(ReticleMark::new(down, 0.0, MarkKind::Hash));
434 for step in 1..=row {
435 let spread = spread_step_mil * step as f64;
436 marks.push(ReticleMark::new(down, spread, MarkKind::Dot));
437 marks.push(ReticleMark::new(down, -spread, MarkKind::Dot));
438 }
439 }
440 Ok(Self {
441 name: format!("tree {rows}x{row_spacing_mil}/{spread_step_mil}"),
442 focal_plane: FocalPlane::First,
443 reference_magnification: 1.0,
444 marks,
445 })
446 }
447
448 pub fn bdc_from_drops(drops: &[(f64, f64)]) -> Result<Self, ReticleError> {
455 if drops.is_empty() {
456 return Err(ReticleError::InvalidGeneratorParameter {
457 parameter: "drops",
458 value: 0.0,
459 rule: "a non-empty list of (range, drop) pairs",
460 });
461 }
462 require_generated_size(1 + drops.len())?;
463 for &(range_m, drop_mil) in drops {
464 if !range_m.is_finite() || range_m <= 0.0 {
465 return Err(ReticleError::InvalidGeneratorParameter {
466 parameter: "drop range",
467 value: range_m,
468 rule: "finite and greater than zero",
469 });
470 }
471 if !drop_mil.is_finite() {
472 return Err(ReticleError::InvalidGeneratorParameter {
473 parameter: "drop",
474 value: drop_mil,
475 rule: "finite",
476 });
477 }
478 }
479
480 let mut marks = Vec::with_capacity(1 + drops.len());
481 marks.push(ReticleMark::new(0.0, 0.0, MarkKind::Center));
482 for &(range_m, drop_mil) in drops {
483 marks.push(ReticleMark::labeled(
484 drop_mil,
485 0.0,
486 MarkKind::Hash,
487 format!("{range_m} m"),
488 ));
489 }
490 Ok(Self {
491 name: format!("bdc {} marks", drops.len()),
492 focal_plane: FocalPlane::First,
493 reference_magnification: 1.0,
494 marks,
495 })
496 }
497}
498
499fn require_positive_magnification(magnification: f64) -> Result<(), ReticleError> {
500 if !magnification.is_finite() || magnification <= 0.0 {
501 return Err(ReticleError::NonPositiveMagnification { magnification });
502 }
503 Ok(())
504}
505
506fn require_generator_positive(parameter: &'static str, value: f64) -> Result<(), ReticleError> {
507 if !value.is_finite() || value <= 0.0 {
508 return Err(ReticleError::InvalidGeneratorParameter {
509 parameter,
510 value,
511 rule: "finite and greater than zero",
512 });
513 }
514 Ok(())
515}
516
517fn require_generated_size(count: usize) -> Result<(), ReticleError> {
518 if count > MAX_RETICLE_MARKS {
519 return Err(ReticleError::TooManyMarks {
520 count,
521 max: MAX_RETICLE_MARKS,
522 });
523 }
524 Ok(())
525}
526
527fn require_generated_size_checked(count: Option<usize>) -> Result<(), ReticleError> {
533 match count {
534 Some(c) => require_generated_size(c),
535 None => Err(ReticleError::TooManyMarks {
536 count: usize::MAX,
537 max: MAX_RETICLE_MARKS,
538 }),
539 }
540}
541
542pub fn hold_point_in_reticle(
561 drop_mil: f64,
562 wind_mil: f64,
563 magnification: f64,
564 reticle: &ReticleDescription,
565) -> Result<ReticleHold, ReticleError> {
566 reticle.validate()?;
567 require_positive_magnification(magnification)?;
568 if !drop_mil.is_finite() || !wind_mil.is_finite() {
569 return Err(ReticleError::NonFiniteHold { drop_mil, wind_mil });
570 }
571
572 let scale = reticle.mark_scale(magnification);
573 let scaled: Vec<ScaledMark> = reticle
574 .marks
575 .iter()
576 .map(|mark| ScaledMark {
577 down_mil: mark.down_mil * scale,
578 right_mil: mark.right_mil * scale,
579 })
580 .collect();
581
582 let mut nearest_index = 0usize;
585 let mut nearest_distance = f64::INFINITY;
586 for (index, mark) in scaled.iter().enumerate() {
587 let distance = ((drop_mil - mark.down_mil).powi(2) + (wind_mil - mark.right_mil).powi(2))
588 .sqrt();
589 if distance < nearest_distance {
590 nearest_distance = distance;
591 nearest_index = index;
592 }
593 }
594
595 let (down_lo, down_hi) = span(scaled.iter().map(|m| m.down_mil));
596 let (right_lo, right_hi) = span(scaled.iter().map(|m| m.right_mil));
597 let down_margin = OFF_RETICLE_MARGIN_FRACTION * (down_hi - down_lo);
598 let right_margin = OFF_RETICLE_MARGIN_FRACTION * (right_hi - right_lo);
599 let off_reticle = drop_mil < down_lo - down_margin
600 || drop_mil > down_hi + down_margin
601 || wind_mil < right_lo - right_margin
602 || wind_mil > right_hi + right_margin;
603
604 Ok(ReticleHold {
605 down_mil: drop_mil,
606 right_mil: wind_mil,
607 nearest_mark: Some(nearest_index),
608 nearest_mark_distance_mil: nearest_distance,
609 off_reticle,
610 mark_scale: scale,
611 })
612}
613
614fn span(values: impl Iterator<Item = f64>) -> (f64, f64) {
616 let mut lo = f64::INFINITY;
617 let mut hi = f64::NEG_INFINITY;
618 for value in values {
619 if value < lo {
620 lo = value;
621 }
622 if value > hi {
623 hi = value;
624 }
625 }
626 (lo, hi)
627}
628
629#[derive(Debug, Clone, Copy, PartialEq, Eq)]
631pub enum ReticleFormat {
632 Table,
633 Json,
634}
635
636pub fn format_reticle_hold(
645 hold: &ReticleHold,
646 reticle: &ReticleDescription,
647 magnification: f64,
648 format: ReticleFormat,
649) -> String {
650 let nearest = hold.nearest_mark.and_then(|index| reticle.marks.get(index));
651 match format {
652 ReticleFormat::Json => {
653 let mut value = serde_json::json!({
654 "reticle": reticle.name,
655 "focal_plane": reticle.focal_plane.label(),
656 "reference_magnification": reticle.reference_magnification,
657 "magnification": magnification,
658 "mark_scale": hold.mark_scale,
659 "hold": {
660 "down_mil": hold.down_mil,
661 "right_mil": hold.right_mil,
662 },
663 "off_reticle": hold.off_reticle,
664 "nearest_mark": serde_json::Value::Null,
665 });
666 if let (Some(index), Some(mark)) = (hold.nearest_mark, nearest) {
667 let scale = hold.mark_scale;
668 value["nearest_mark"] = serde_json::json!({
669 "index": index,
670 "kind": mark.kind.as_str(),
671 "label": mark.label,
672 "nominal_down_mil": mark.down_mil,
673 "nominal_right_mil": mark.right_mil,
674 "true_down_mil": mark.down_mil * scale,
675 "true_right_mil": mark.right_mil * scale,
676 "distance_mil": hold.nearest_mark_distance_mil,
677 });
678 }
679 format!(
680 "{}\n",
681 serde_json::to_string_pretty(&value)
682 .unwrap_or_else(|_| "{\"error\":\"serialization failed\"}".to_string())
683 )
684 }
685 ReticleFormat::Table => {
686 let mut out = String::new();
687 out.push_str("Reticle Hold Point\n");
688 out.push_str("==================\n\n");
689 out.push_str(&format!("Reticle: {}\n", reticle.name));
690 out.push_str(&format!(
691 "Focal plane: {}\n",
692 reticle.focal_plane.label()
693 ));
694 if reticle.focal_plane.is_magnification_dependent() {
695 out.push_str(&format!(
696 "Reference mag: {:.2}x\n",
697 reticle.reference_magnification
698 ));
699 out.push_str(&format!("Magnification: {magnification:.2}x\n"));
700 out.push_str(&format!(
701 "Subtension scale: {:.4}x (marks read {:.4}x their etched value)\n",
702 hold.mark_scale, hold.mark_scale
703 ));
704 } else {
705 out.push_str(&format!(
706 "Magnification: {magnification:.2}x (FFP: subtensions are magnification-independent)\n"
707 ));
708 }
709 out.push('\n');
710 out.push_str(&format!("Hold down: {:>8.3} mil\n", hold.down_mil));
711 out.push_str(&format!("Hold right: {:>8.3} mil\n", hold.right_mil));
712 out.push('\n');
713 match nearest {
714 Some(mark) => {
715 let scale = hold.mark_scale;
716 let label = mark.label.as_deref().unwrap_or("-");
717 out.push_str(&format!(
718 "Nearest mark: #{} {} ({})\n",
719 hold.nearest_mark.unwrap_or(0),
720 mark.kind.as_str(),
721 label
722 ));
723 out.push_str(&format!(
724 " at (down {:.3}, right {:.3}) mil true\n",
725 mark.down_mil * scale,
726 mark.right_mil * scale
727 ));
728 out.push_str(&format!(
729 " distance from hold: {:.3} mil\n",
730 hold.nearest_mark_distance_mil
731 ));
732 }
733 None => out.push_str("Nearest mark: none\n"),
734 }
735 if hold.off_reticle {
736 out.push_str(
737 "\nWARNING: the hold falls outside the marked area of this reticle \
738 (dial instead, or use a reticle with more holdover).\n",
739 );
740 }
741 out
742 }
743 }
744}
745
746pub fn format_reticle_description(reticle: &ReticleDescription, format: ReticleFormat) -> String {
751 match format {
752 ReticleFormat::Json => format!(
753 "{}\n",
754 serde_json::to_string_pretty(reticle)
755 .unwrap_or_else(|_| "{\"error\":\"serialization failed\"}".to_string())
756 ),
757 ReticleFormat::Table => {
758 let mut out = String::new();
759 out.push_str(&format!("Reticle: {}\n", reticle.name));
760 out.push_str(&format!(
761 "Focal plane: {}",
762 reticle.focal_plane.label()
763 ));
764 if reticle.focal_plane.is_magnification_dependent() {
765 out.push_str(&format!(
766 " Reference magnification: {:.2}x",
767 reticle.reference_magnification
768 ));
769 }
770 out.push_str(&format!(" Marks: {}\n\n", reticle.marks.len()));
771 out.push_str(" # Kind Down(mil) Right(mil) Label\n");
772 out.push_str("---- ------- ---------- ----------- --------------------\n");
773 for (index, mark) in reticle.marks.iter().enumerate() {
774 out.push_str(&format!(
775 "{:>4} {:<7} {:>10.3} {:>11.3} {}\n",
776 index,
777 mark.kind.as_str(),
778 mark.down_mil,
779 mark.right_mil,
780 mark.label.as_deref().unwrap_or("-")
781 ));
782 }
783 out
784 }
785 }
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791
792 fn sfp_two_mil_at_ten() -> ReticleDescription {
793 ReticleDescription {
794 name: "test sfp".to_string(),
795 focal_plane: FocalPlane::Second,
796 reference_magnification: 10.0,
797 marks: vec![
798 ReticleMark::new(0.0, 0.0, MarkKind::Center),
799 ReticleMark::new(2.0, 0.0, MarkKind::Hash),
800 ReticleMark::new(4.0, 0.0, MarkKind::Hash),
801 ],
802 }
803 }
804
805 fn ffp_ladder() -> ReticleDescription {
806 ReticleDescription {
807 name: "test ffp".to_string(),
808 focal_plane: FocalPlane::First,
809 reference_magnification: 1.0,
810 marks: vec![
811 ReticleMark::new(0.0, 0.0, MarkKind::Center),
812 ReticleMark::new(2.0, 0.0, MarkKind::Hash),
813 ReticleMark::new(4.0, 0.0, MarkKind::Hash),
814 ReticleMark::new(2.0, 1.0, MarkKind::Dot),
815 ReticleMark::new(2.0, -1.0, MarkKind::Dot),
816 ],
817 }
818 }
819
820 #[test]
821 fn ffp_marks_are_invariant_across_magnification() {
822 let reticle = ffp_ladder();
823 let a = hold_point_in_reticle(2.3, 0.4, 4.0, &reticle).unwrap();
824 let b = hold_point_in_reticle(2.3, 0.4, 25.0, &reticle).unwrap();
825 assert_eq!(a, b, "FFP hold must not depend on magnification");
826 assert_eq!(a.mark_scale, 1.0);
827 }
828
829 #[test]
830 fn sfp_marks_scale_by_reference_over_current_magnification() {
831 let reticle = sfp_two_mil_at_ten();
832
833 let scaled = reticle.scaled_marks(10.0).unwrap();
835 assert_eq!(scaled[1].down_mil, 2.0);
836
837 let scaled = reticle.scaled_marks(5.0).unwrap();
839 assert_eq!(scaled[1].down_mil, 4.0);
840 assert_eq!(scaled[2].down_mil, 8.0);
841
842 let scaled = reticle.scaled_marks(20.0).unwrap();
844 assert_eq!(scaled[1].down_mil, 1.0);
845 }
846
847 #[test]
848 fn sfp_nearest_mark_is_measured_in_true_angular_space() {
849 let reticle = sfp_two_mil_at_ten();
850 let hold = hold_point_in_reticle(4.0, 0.0, 5.0, &reticle).unwrap();
853 assert_eq!(hold.nearest_mark, Some(1));
854 assert_eq!(hold.nearest_mark_distance_mil, 0.0);
855 assert_eq!(hold.down_mil, 4.0, "the hold stays TRUE angular");
856 assert_eq!(hold.mark_scale, 2.0);
857
858 let hold = hold_point_in_reticle(4.0, 0.0, 10.0, &reticle).unwrap();
860 assert_eq!(hold.nearest_mark, Some(2));
861 assert_eq!(hold.nearest_mark_distance_mil, 0.0);
862 }
863
864 #[test]
865 fn hold_on_a_mark_has_zero_distance() {
866 let reticle = ffp_ladder();
867 let hold = hold_point_in_reticle(2.0, 1.0, 10.0, &reticle).unwrap();
868 assert_eq!(hold.nearest_mark, Some(3));
869 assert_eq!(hold.nearest_mark_distance_mil, 0.0);
870 assert!(!hold.off_reticle);
871 }
872
873 #[test]
874 fn off_reticle_boundary_follows_the_documented_margin() {
875 let reticle = ffp_ladder();
876 let inside = hold_point_in_reticle(4.8, 0.0, 10.0, &reticle).unwrap();
878 assert!(!inside.off_reticle, "exactly on the margin counts as on-reticle");
879 let outside = hold_point_in_reticle(4.80001, 0.0, 10.0, &reticle).unwrap();
880 assert!(outside.off_reticle);
881
882 let inside = hold_point_in_reticle(2.0, 1.4, 10.0, &reticle).unwrap();
883 assert!(!inside.off_reticle);
884 let outside = hold_point_in_reticle(2.0, 1.40001, 10.0, &reticle).unwrap();
885 assert!(outside.off_reticle);
886
887 assert!(hold_point_in_reticle(-0.9, 0.0, 10.0, &reticle).unwrap().off_reticle);
889 }
890
891 #[test]
892 fn sfp_off_reticle_uses_the_scaled_bounding_box() {
893 let reticle = sfp_two_mil_at_ten();
894 assert!(!hold_point_in_reticle(9.0, 0.0, 5.0, &reticle).unwrap().off_reticle);
896 assert!(hold_point_in_reticle(9.0, 0.0, 20.0, &reticle).unwrap().off_reticle);
898 }
899
900 #[test]
901 fn rejects_non_physical_magnification_on_both_planes() {
902 for reticle in [ffp_ladder(), sfp_two_mil_at_ten()] {
903 assert_eq!(
904 hold_point_in_reticle(1.0, 0.0, 0.0, &reticle),
905 Err(ReticleError::NonPositiveMagnification { magnification: 0.0 })
906 );
907 assert!(matches!(
908 hold_point_in_reticle(1.0, 0.0, -3.0, &reticle),
909 Err(ReticleError::NonPositiveMagnification { .. })
910 ));
911 assert!(matches!(
912 hold_point_in_reticle(1.0, 0.0, f64::NAN, &reticle),
913 Err(ReticleError::NonPositiveMagnification { .. })
914 ));
915 }
916 }
917
918 #[test]
919 fn sfp_rejects_a_non_positive_reference_magnification_but_ffp_ignores_it() {
920 let mut sfp = sfp_two_mil_at_ten();
921 sfp.reference_magnification = 0.0;
922 assert!(matches!(
923 hold_point_in_reticle(1.0, 0.0, 10.0, &sfp),
924 Err(ReticleError::NonPositiveReferenceMagnification { .. })
925 ));
926
927 let mut ffp = ffp_ladder();
928 ffp.reference_magnification = 0.0;
929 assert!(
930 hold_point_in_reticle(1.0, 0.0, 10.0, &ffp).is_ok(),
931 "FFP never consults the reference magnification"
932 );
933 }
934
935 #[test]
936 fn rejects_empty_and_non_finite_descriptions() {
937 let mut reticle = ffp_ladder();
938 reticle.marks.clear();
939 assert_eq!(
940 hold_point_in_reticle(1.0, 0.0, 10.0, &reticle),
941 Err(ReticleError::NoMarks)
942 );
943
944 let mut reticle = ffp_ladder();
945 reticle.marks[2].down_mil = f64::NAN;
946 assert_eq!(
947 hold_point_in_reticle(1.0, 0.0, 10.0, &reticle),
948 Err(ReticleError::NonFiniteMark { index: 2 })
949 );
950
951 let reticle = ffp_ladder();
952 assert!(matches!(
953 hold_point_in_reticle(f64::NAN, 0.0, 10.0, &reticle),
954 Err(ReticleError::NonFiniteHold { .. })
955 ));
956 }
957
958 #[test]
959 fn mil_grid_generates_a_cross_not_a_filled_grid() {
960 let reticle = ReticleDescription::mil_grid(0.5, 2.0).unwrap();
961 assert_eq!(reticle.marks.len(), 1 + 4 * 4);
963 assert_eq!(reticle.marks[0].kind, MarkKind::Center);
964 for mark in &reticle.marks[1..] {
967 assert!(
968 mark.down_mil == 0.0 || mark.right_mil == 0.0,
969 "mil_grid must not produce off-axis marks"
970 );
971 }
972 assert_eq!(reticle.focal_plane, FocalPlane::First);
973 }
974
975 #[test]
976 fn tree_widens_one_step_per_row() {
977 let reticle = ReticleDescription::tree(3, 1.0, 0.5).unwrap();
978 assert_eq!(reticle.marks.len(), 1 + 3 + 2 * (1 + 2 + 3));
980 let widest_in_row = |down: f64| {
981 reticle
982 .marks
983 .iter()
984 .filter(|m| m.down_mil == down)
985 .map(|m| m.right_mil.abs())
986 .fold(0.0_f64, f64::max)
987 };
988 assert_eq!(widest_in_row(1.0), 0.5);
989 assert_eq!(widest_in_row(2.0), 1.0);
990 assert_eq!(widest_in_row(3.0), 1.5);
991 }
992
993 #[test]
994 fn bdc_from_drops_is_pure_data_assembly() {
995 let reticle =
996 ReticleDescription::bdc_from_drops(&[(300.0, 1.2), (400.0, 2.4), (500.0, 4.1)]).unwrap();
997 assert_eq!(reticle.marks.len(), 4);
998 assert_eq!(reticle.marks[0].kind, MarkKind::Center);
999 assert_eq!(reticle.marks[1].down_mil, 1.2);
1000 assert_eq!(reticle.marks[1].label.as_deref(), Some("300 m"));
1001 assert_eq!(reticle.marks[3].down_mil, 4.1);
1002 assert!(reticle.marks.iter().all(|m| m.right_mil == 0.0));
1004 }
1005
1006 #[test]
1007 fn generators_reject_bad_parameters() {
1008 assert!(ReticleDescription::mil_grid(0.0, 5.0).is_err());
1009 assert!(ReticleDescription::mil_grid(0.5, 0.0).is_err());
1010 assert!(ReticleDescription::mil_grid(5.0, 1.0).is_err());
1011 assert!(ReticleDescription::tree(0, 1.0, 0.5).is_err());
1012 assert!(ReticleDescription::tree(3, -1.0, 0.5).is_err());
1013 assert!(ReticleDescription::bdc_from_drops(&[]).is_err());
1014 assert!(ReticleDescription::bdc_from_drops(&[(0.0, 1.0)]).is_err());
1015 assert!(ReticleDescription::bdc_from_drops(&[(100.0, f64::NAN)]).is_err());
1016 assert!(matches!(
1018 ReticleDescription::mil_grid(0.001, 10.0),
1019 Err(ReticleError::TooManyMarks { .. })
1020 ));
1021 }
1022
1023 #[test]
1024 fn generator_sizes_cannot_overflow_past_the_cap() {
1025 let huge = (usize::MAX / 2) as f64; assert!(matches!(
1031 ReticleDescription::mil_grid(1.0, huge),
1032 Err(ReticleError::TooManyMarks { .. })
1033 ));
1034 assert!(matches!(
1035 ReticleDescription::tree(usize::MAX / 2, 1.0, 0.5),
1036 Err(ReticleError::TooManyMarks { .. })
1037 ));
1038 assert!(matches!(
1040 ReticleDescription::tree(1000, 1.0, 0.5),
1041 Err(ReticleError::TooManyMarks { .. })
1042 ));
1043 }
1044
1045 #[test]
1046 fn description_round_trips_through_serde() {
1047 let reticle = ReticleDescription {
1048 name: "round trip".to_string(),
1049 focal_plane: FocalPlane::Second,
1050 reference_magnification: 12.0,
1051 marks: vec![
1052 ReticleMark::new(0.0, 0.0, MarkKind::Center),
1053 ReticleMark::labeled(3.5, -1.5, MarkKind::Dot, "600 yd"),
1054 ReticleMark::new(6.0, 0.0, MarkKind::Post),
1055 ],
1056 };
1057 let json = serde_json::to_string(&reticle).unwrap();
1058 assert!(json.contains("\"sfp\""), "focal plane serializes as sfp: {json}");
1059 assert!(json.contains("\"center\""));
1060 let back: ReticleDescription = serde_json::from_str(&json).unwrap();
1061 assert_eq!(back, reticle);
1062
1063 let json = serde_json::to_string(&ReticleMark::new(1.0, 0.0, MarkKind::Hash)).unwrap();
1065 assert!(!json.contains("label"), "{json}");
1066
1067 let permissive: ReticleDescription = serde_json::from_str(
1069 r#"{"name":"x","focal_plane":"ffp","reference_magnification":1.0,
1070 "marks":[{"down_mil":1.0,"right_mil":0.0,"kind":"hash"}],"stroke":"thin"}"#,
1071 )
1072 .unwrap();
1073 assert_eq!(permissive.marks.len(), 1);
1074 }
1075
1076 #[test]
1077 fn hold_round_trips_through_serde() {
1078 let reticle = ffp_ladder();
1079 let hold = hold_point_in_reticle(3.1, 0.6, 10.0, &reticle).unwrap();
1080 let back: ReticleHold = serde_json::from_str(&serde_json::to_string(&hold).unwrap()).unwrap();
1081 assert_eq!(back, hold);
1082 }
1083
1084 #[test]
1085 fn formatters_are_stable_and_json_is_the_schema() {
1086 let reticle = ReticleDescription::bdc_from_drops(&[(300.0, 1.2)]).unwrap();
1087 let json = format_reticle_description(&reticle, ReticleFormat::Json);
1088 assert!(json.ends_with('\n'));
1089 let back: ReticleDescription = serde_json::from_str(&json).unwrap();
1090 assert_eq!(back, reticle, "generate -o json feeds hold --reticle-json");
1091
1092 let hold = hold_point_in_reticle(1.2, 0.0, 10.0, &reticle).unwrap();
1093 let table = format_reticle_hold(&hold, &reticle, 10.0, ReticleFormat::Table);
1094 assert!(table.contains("Hold down:"));
1095 assert!(table.contains("300 m"));
1096 assert!(!table.contains("Subtension scale"), "FFP hides SFP-only rows");
1097
1098 let sfp = sfp_two_mil_at_ten();
1099 let hold = hold_point_in_reticle(4.0, 0.0, 5.0, &sfp).unwrap();
1100 let table = format_reticle_hold(&hold, &sfp, 5.0, ReticleFormat::Table);
1101 assert!(table.contains("Subtension scale: 2.0000x"));
1102 }
1103}