use std::error::Error;
use std::fmt;
use serde::{Deserialize, Serialize};
pub const MAX_RETICLE_MARKS: usize = 4096;
pub const OFF_RETICLE_MARGIN_FRACTION: f64 = 0.20;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum FocalPlane {
#[serde(rename = "ffp")]
#[default]
First,
#[serde(rename = "sfp")]
Second,
}
impl FocalPlane {
pub fn label(self) -> &'static str {
match self {
FocalPlane::First => "FFP",
FocalPlane::Second => "SFP",
}
}
pub fn is_magnification_dependent(self) -> bool {
matches!(self, FocalPlane::Second)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MarkKind {
#[default]
Dot,
Hash,
Post,
Center,
}
impl MarkKind {
pub fn as_str(self) -> &'static str {
match self {
MarkKind::Dot => "dot",
MarkKind::Hash => "hash",
MarkKind::Post => "post",
MarkKind::Center => "center",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ReticleMark {
pub down_mil: f64,
pub right_mil: f64,
#[serde(default)]
pub kind: MarkKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
impl ReticleMark {
pub fn new(down_mil: f64, right_mil: f64, kind: MarkKind) -> Self {
Self {
down_mil,
right_mil,
kind,
label: None,
}
}
pub fn labeled(down_mil: f64, right_mil: f64, kind: MarkKind, label: impl Into<String>) -> Self {
Self {
down_mil,
right_mil,
kind,
label: Some(label.into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ReticleDescription {
pub name: String,
pub focal_plane: FocalPlane,
pub reference_magnification: f64,
pub marks: Vec<ReticleMark>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ReticleError {
NonPositiveMagnification { magnification: f64 },
NonPositiveReferenceMagnification { reference_magnification: f64 },
NoMarks,
TooManyMarks { count: usize, max: usize },
NonFiniteMark { index: usize },
NonFiniteHold { drop_mil: f64, wind_mil: f64 },
InvalidGeneratorParameter {
parameter: &'static str,
value: f64,
rule: &'static str,
},
}
impl fmt::Display for ReticleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ReticleError::NonPositiveMagnification { magnification } => write!(
f,
"magnification must be finite and greater than zero (got {magnification})"
),
ReticleError::NonPositiveReferenceMagnification {
reference_magnification,
} => write!(
f,
"an SFP reticle's reference magnification must be finite and greater than \
zero (got {reference_magnification})"
),
ReticleError::NoMarks => {
write!(f, "the reticle description carries no marks")
}
ReticleError::TooManyMarks { count, max } => write!(
f,
"the reticle description carries {count} marks, more than the supported maximum of {max}"
),
ReticleError::NonFiniteMark { index } => write!(
f,
"reticle mark {index} has non-finite coordinates"
),
ReticleError::NonFiniteHold { drop_mil, wind_mil } => write!(
f,
"the hold must be finite (got drop {drop_mil} mil, wind {wind_mil} mil)"
),
ReticleError::InvalidGeneratorParameter {
parameter,
value,
rule,
} => write!(f, "{parameter} must be {rule} (got {value})"),
}
}
}
impl Error for ReticleError {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ReticleHold {
pub down_mil: f64,
pub right_mil: f64,
pub nearest_mark: Option<usize>,
pub nearest_mark_distance_mil: f64,
pub off_reticle: bool,
pub mark_scale: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ScaledMark {
pub down_mil: f64,
pub right_mil: f64,
}
impl ReticleDescription {
pub fn validate(&self) -> Result<(), ReticleError> {
if self.marks.is_empty() {
return Err(ReticleError::NoMarks);
}
if self.marks.len() > MAX_RETICLE_MARKS {
return Err(ReticleError::TooManyMarks {
count: self.marks.len(),
max: MAX_RETICLE_MARKS,
});
}
for (index, mark) in self.marks.iter().enumerate() {
if !mark.down_mil.is_finite() || !mark.right_mil.is_finite() {
return Err(ReticleError::NonFiniteMark { index });
}
}
if self.focal_plane.is_magnification_dependent()
&& (!self.reference_magnification.is_finite() || self.reference_magnification <= 0.0)
{
return Err(ReticleError::NonPositiveReferenceMagnification {
reference_magnification: self.reference_magnification,
});
}
Ok(())
}
pub fn mark_scale(&self, magnification: f64) -> f64 {
match self.focal_plane {
FocalPlane::First => 1.0,
FocalPlane::Second => self.reference_magnification / magnification,
}
}
pub fn scaled_marks(&self, magnification: f64) -> Result<Vec<ScaledMark>, ReticleError> {
self.validate()?;
require_positive_magnification(magnification)?;
let scale = self.mark_scale(magnification);
Ok(self
.marks
.iter()
.map(|mark| ScaledMark {
down_mil: mark.down_mil * scale,
right_mil: mark.right_mil * scale,
})
.collect())
}
pub fn mil_grid(spacing_mil: f64, extent_mil: f64) -> Result<Self, ReticleError> {
require_generator_positive("spacing", spacing_mil)?;
require_generator_positive("extent", extent_mil)?;
if extent_mil < spacing_mil {
return Err(ReticleError::InvalidGeneratorParameter {
parameter: "extent",
value: extent_mil,
rule: "greater than or equal to the spacing",
});
}
let steps = (extent_mil / spacing_mil).floor() as usize;
require_generated_size_checked(4usize.checked_mul(steps).and_then(|v| v.checked_add(1)))?;
let mut marks = Vec::with_capacity(1 + 4 * steps);
marks.push(ReticleMark::new(0.0, 0.0, MarkKind::Center));
for step in 1..=steps {
let offset = spacing_mil * step as f64;
marks.push(ReticleMark::new(offset, 0.0, MarkKind::Hash));
marks.push(ReticleMark::new(-offset, 0.0, MarkKind::Hash));
marks.push(ReticleMark::new(0.0, offset, MarkKind::Hash));
marks.push(ReticleMark::new(0.0, -offset, MarkKind::Hash));
}
Ok(Self {
name: format!("mil-cross {spacing_mil}/{extent_mil}"),
focal_plane: FocalPlane::First,
reference_magnification: 1.0,
marks,
})
}
pub fn tree(
rows: usize,
row_spacing_mil: f64,
spread_step_mil: f64,
) -> Result<Self, ReticleError> {
if rows == 0 {
return Err(ReticleError::InvalidGeneratorParameter {
parameter: "rows",
value: 0.0,
rule: "at least 1",
});
}
require_generator_positive("row-spacing", row_spacing_mil)?;
require_generator_positive("spread-step", spread_step_mil)?;
require_generated_size_checked(
rows.checked_add(1)
.and_then(|r1| rows.checked_mul(r1))
.and_then(|p| p.checked_add(rows))
.and_then(|p| p.checked_add(1)),
)?;
let mut marks = Vec::new();
marks.push(ReticleMark::new(0.0, 0.0, MarkKind::Center));
for row in 1..=rows {
let down = row_spacing_mil * row as f64;
marks.push(ReticleMark::new(down, 0.0, MarkKind::Hash));
for step in 1..=row {
let spread = spread_step_mil * step as f64;
marks.push(ReticleMark::new(down, spread, MarkKind::Dot));
marks.push(ReticleMark::new(down, -spread, MarkKind::Dot));
}
}
Ok(Self {
name: format!("tree {rows}x{row_spacing_mil}/{spread_step_mil}"),
focal_plane: FocalPlane::First,
reference_magnification: 1.0,
marks,
})
}
pub fn bdc_from_drops(drops: &[(f64, f64)]) -> Result<Self, ReticleError> {
if drops.is_empty() {
return Err(ReticleError::InvalidGeneratorParameter {
parameter: "drops",
value: 0.0,
rule: "a non-empty list of (range, drop) pairs",
});
}
require_generated_size(1 + drops.len())?;
for &(range_m, drop_mil) in drops {
if !range_m.is_finite() || range_m <= 0.0 {
return Err(ReticleError::InvalidGeneratorParameter {
parameter: "drop range",
value: range_m,
rule: "finite and greater than zero",
});
}
if !drop_mil.is_finite() {
return Err(ReticleError::InvalidGeneratorParameter {
parameter: "drop",
value: drop_mil,
rule: "finite",
});
}
}
let mut marks = Vec::with_capacity(1 + drops.len());
marks.push(ReticleMark::new(0.0, 0.0, MarkKind::Center));
for &(range_m, drop_mil) in drops {
marks.push(ReticleMark::labeled(
drop_mil,
0.0,
MarkKind::Hash,
format!("{range_m} m"),
));
}
Ok(Self {
name: format!("bdc {} marks", drops.len()),
focal_plane: FocalPlane::First,
reference_magnification: 1.0,
marks,
})
}
}
fn require_positive_magnification(magnification: f64) -> Result<(), ReticleError> {
if !magnification.is_finite() || magnification <= 0.0 {
return Err(ReticleError::NonPositiveMagnification { magnification });
}
Ok(())
}
fn require_generator_positive(parameter: &'static str, value: f64) -> Result<(), ReticleError> {
if !value.is_finite() || value <= 0.0 {
return Err(ReticleError::InvalidGeneratorParameter {
parameter,
value,
rule: "finite and greater than zero",
});
}
Ok(())
}
fn require_generated_size(count: usize) -> Result<(), ReticleError> {
if count > MAX_RETICLE_MARKS {
return Err(ReticleError::TooManyMarks {
count,
max: MAX_RETICLE_MARKS,
});
}
Ok(())
}
fn require_generated_size_checked(count: Option<usize>) -> Result<(), ReticleError> {
match count {
Some(c) => require_generated_size(c),
None => Err(ReticleError::TooManyMarks {
count: usize::MAX,
max: MAX_RETICLE_MARKS,
}),
}
}
pub fn hold_point_in_reticle(
drop_mil: f64,
wind_mil: f64,
magnification: f64,
reticle: &ReticleDescription,
) -> Result<ReticleHold, ReticleError> {
reticle.validate()?;
require_positive_magnification(magnification)?;
if !drop_mil.is_finite() || !wind_mil.is_finite() {
return Err(ReticleError::NonFiniteHold { drop_mil, wind_mil });
}
let scale = reticle.mark_scale(magnification);
let scaled: Vec<ScaledMark> = reticle
.marks
.iter()
.map(|mark| ScaledMark {
down_mil: mark.down_mil * scale,
right_mil: mark.right_mil * scale,
})
.collect();
let mut nearest_index = 0usize;
let mut nearest_distance = f64::INFINITY;
for (index, mark) in scaled.iter().enumerate() {
let distance = ((drop_mil - mark.down_mil).powi(2) + (wind_mil - mark.right_mil).powi(2))
.sqrt();
if distance < nearest_distance {
nearest_distance = distance;
nearest_index = index;
}
}
let (down_lo, down_hi) = span(scaled.iter().map(|m| m.down_mil));
let (right_lo, right_hi) = span(scaled.iter().map(|m| m.right_mil));
let down_margin = OFF_RETICLE_MARGIN_FRACTION * (down_hi - down_lo);
let right_margin = OFF_RETICLE_MARGIN_FRACTION * (right_hi - right_lo);
let off_reticle = drop_mil < down_lo - down_margin
|| drop_mil > down_hi + down_margin
|| wind_mil < right_lo - right_margin
|| wind_mil > right_hi + right_margin;
Ok(ReticleHold {
down_mil: drop_mil,
right_mil: wind_mil,
nearest_mark: Some(nearest_index),
nearest_mark_distance_mil: nearest_distance,
off_reticle,
mark_scale: scale,
})
}
fn span(values: impl Iterator<Item = f64>) -> (f64, f64) {
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for value in values {
if value < lo {
lo = value;
}
if value > hi {
hi = value;
}
}
(lo, hi)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReticleFormat {
Table,
Json,
}
pub fn format_reticle_hold(
hold: &ReticleHold,
reticle: &ReticleDescription,
magnification: f64,
format: ReticleFormat,
) -> String {
let nearest = hold.nearest_mark.and_then(|index| reticle.marks.get(index));
match format {
ReticleFormat::Json => {
let mut value = serde_json::json!({
"reticle": reticle.name,
"focal_plane": reticle.focal_plane.label(),
"reference_magnification": reticle.reference_magnification,
"magnification": magnification,
"mark_scale": hold.mark_scale,
"hold": {
"down_mil": hold.down_mil,
"right_mil": hold.right_mil,
},
"off_reticle": hold.off_reticle,
"nearest_mark": serde_json::Value::Null,
});
if let (Some(index), Some(mark)) = (hold.nearest_mark, nearest) {
let scale = hold.mark_scale;
value["nearest_mark"] = serde_json::json!({
"index": index,
"kind": mark.kind.as_str(),
"label": mark.label,
"nominal_down_mil": mark.down_mil,
"nominal_right_mil": mark.right_mil,
"true_down_mil": mark.down_mil * scale,
"true_right_mil": mark.right_mil * scale,
"distance_mil": hold.nearest_mark_distance_mil,
});
}
format!(
"{}\n",
serde_json::to_string_pretty(&value)
.unwrap_or_else(|_| "{\"error\":\"serialization failed\"}".to_string())
)
}
ReticleFormat::Table => {
let mut out = String::new();
out.push_str("Reticle Hold Point\n");
out.push_str("==================\n\n");
out.push_str(&format!("Reticle: {}\n", reticle.name));
out.push_str(&format!(
"Focal plane: {}\n",
reticle.focal_plane.label()
));
if reticle.focal_plane.is_magnification_dependent() {
out.push_str(&format!(
"Reference mag: {:.2}x\n",
reticle.reference_magnification
));
out.push_str(&format!("Magnification: {magnification:.2}x\n"));
out.push_str(&format!(
"Subtension scale: {:.4}x (marks read {:.4}x their etched value)\n",
hold.mark_scale, hold.mark_scale
));
} else {
out.push_str(&format!(
"Magnification: {magnification:.2}x (FFP: subtensions are magnification-independent)\n"
));
}
out.push('\n');
out.push_str(&format!("Hold down: {:>8.3} mil\n", hold.down_mil));
out.push_str(&format!("Hold right: {:>8.3} mil\n", hold.right_mil));
out.push('\n');
match nearest {
Some(mark) => {
let scale = hold.mark_scale;
let label = mark.label.as_deref().unwrap_or("-");
out.push_str(&format!(
"Nearest mark: #{} {} ({})\n",
hold.nearest_mark.unwrap_or(0),
mark.kind.as_str(),
label
));
out.push_str(&format!(
" at (down {:.3}, right {:.3}) mil true\n",
mark.down_mil * scale,
mark.right_mil * scale
));
out.push_str(&format!(
" distance from hold: {:.3} mil\n",
hold.nearest_mark_distance_mil
));
}
None => out.push_str("Nearest mark: none\n"),
}
if hold.off_reticle {
out.push_str(
"\nWARNING: the hold falls outside the marked area of this reticle \
(dial instead, or use a reticle with more holdover).\n",
);
}
out
}
}
}
pub fn format_reticle_description(reticle: &ReticleDescription, format: ReticleFormat) -> String {
match format {
ReticleFormat::Json => format!(
"{}\n",
serde_json::to_string_pretty(reticle)
.unwrap_or_else(|_| "{\"error\":\"serialization failed\"}".to_string())
),
ReticleFormat::Table => {
let mut out = String::new();
out.push_str(&format!("Reticle: {}\n", reticle.name));
out.push_str(&format!(
"Focal plane: {}",
reticle.focal_plane.label()
));
if reticle.focal_plane.is_magnification_dependent() {
out.push_str(&format!(
" Reference magnification: {:.2}x",
reticle.reference_magnification
));
}
out.push_str(&format!(" Marks: {}\n\n", reticle.marks.len()));
out.push_str(" # Kind Down(mil) Right(mil) Label\n");
out.push_str("---- ------- ---------- ----------- --------------------\n");
for (index, mark) in reticle.marks.iter().enumerate() {
out.push_str(&format!(
"{:>4} {:<7} {:>10.3} {:>11.3} {}\n",
index,
mark.kind.as_str(),
mark.down_mil,
mark.right_mil,
mark.label.as_deref().unwrap_or("-")
));
}
out
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sfp_two_mil_at_ten() -> ReticleDescription {
ReticleDescription {
name: "test sfp".to_string(),
focal_plane: FocalPlane::Second,
reference_magnification: 10.0,
marks: vec![
ReticleMark::new(0.0, 0.0, MarkKind::Center),
ReticleMark::new(2.0, 0.0, MarkKind::Hash),
ReticleMark::new(4.0, 0.0, MarkKind::Hash),
],
}
}
fn ffp_ladder() -> ReticleDescription {
ReticleDescription {
name: "test ffp".to_string(),
focal_plane: FocalPlane::First,
reference_magnification: 1.0,
marks: vec![
ReticleMark::new(0.0, 0.0, MarkKind::Center),
ReticleMark::new(2.0, 0.0, MarkKind::Hash),
ReticleMark::new(4.0, 0.0, MarkKind::Hash),
ReticleMark::new(2.0, 1.0, MarkKind::Dot),
ReticleMark::new(2.0, -1.0, MarkKind::Dot),
],
}
}
#[test]
fn ffp_marks_are_invariant_across_magnification() {
let reticle = ffp_ladder();
let a = hold_point_in_reticle(2.3, 0.4, 4.0, &reticle).unwrap();
let b = hold_point_in_reticle(2.3, 0.4, 25.0, &reticle).unwrap();
assert_eq!(a, b, "FFP hold must not depend on magnification");
assert_eq!(a.mark_scale, 1.0);
}
#[test]
fn sfp_marks_scale_by_reference_over_current_magnification() {
let reticle = sfp_two_mil_at_ten();
let scaled = reticle.scaled_marks(10.0).unwrap();
assert_eq!(scaled[1].down_mil, 2.0);
let scaled = reticle.scaled_marks(5.0).unwrap();
assert_eq!(scaled[1].down_mil, 4.0);
assert_eq!(scaled[2].down_mil, 8.0);
let scaled = reticle.scaled_marks(20.0).unwrap();
assert_eq!(scaled[1].down_mil, 1.0);
}
#[test]
fn sfp_nearest_mark_is_measured_in_true_angular_space() {
let reticle = sfp_two_mil_at_ten();
let hold = hold_point_in_reticle(4.0, 0.0, 5.0, &reticle).unwrap();
assert_eq!(hold.nearest_mark, Some(1));
assert_eq!(hold.nearest_mark_distance_mil, 0.0);
assert_eq!(hold.down_mil, 4.0, "the hold stays TRUE angular");
assert_eq!(hold.mark_scale, 2.0);
let hold = hold_point_in_reticle(4.0, 0.0, 10.0, &reticle).unwrap();
assert_eq!(hold.nearest_mark, Some(2));
assert_eq!(hold.nearest_mark_distance_mil, 0.0);
}
#[test]
fn hold_on_a_mark_has_zero_distance() {
let reticle = ffp_ladder();
let hold = hold_point_in_reticle(2.0, 1.0, 10.0, &reticle).unwrap();
assert_eq!(hold.nearest_mark, Some(3));
assert_eq!(hold.nearest_mark_distance_mil, 0.0);
assert!(!hold.off_reticle);
}
#[test]
fn off_reticle_boundary_follows_the_documented_margin() {
let reticle = ffp_ladder();
let inside = hold_point_in_reticle(4.8, 0.0, 10.0, &reticle).unwrap();
assert!(!inside.off_reticle, "exactly on the margin counts as on-reticle");
let outside = hold_point_in_reticle(4.80001, 0.0, 10.0, &reticle).unwrap();
assert!(outside.off_reticle);
let inside = hold_point_in_reticle(2.0, 1.4, 10.0, &reticle).unwrap();
assert!(!inside.off_reticle);
let outside = hold_point_in_reticle(2.0, 1.40001, 10.0, &reticle).unwrap();
assert!(outside.off_reticle);
assert!(hold_point_in_reticle(-0.9, 0.0, 10.0, &reticle).unwrap().off_reticle);
}
#[test]
fn sfp_off_reticle_uses_the_scaled_bounding_box() {
let reticle = sfp_two_mil_at_ten();
assert!(!hold_point_in_reticle(9.0, 0.0, 5.0, &reticle).unwrap().off_reticle);
assert!(hold_point_in_reticle(9.0, 0.0, 20.0, &reticle).unwrap().off_reticle);
}
#[test]
fn rejects_non_physical_magnification_on_both_planes() {
for reticle in [ffp_ladder(), sfp_two_mil_at_ten()] {
assert_eq!(
hold_point_in_reticle(1.0, 0.0, 0.0, &reticle),
Err(ReticleError::NonPositiveMagnification { magnification: 0.0 })
);
assert!(matches!(
hold_point_in_reticle(1.0, 0.0, -3.0, &reticle),
Err(ReticleError::NonPositiveMagnification { .. })
));
assert!(matches!(
hold_point_in_reticle(1.0, 0.0, f64::NAN, &reticle),
Err(ReticleError::NonPositiveMagnification { .. })
));
}
}
#[test]
fn sfp_rejects_a_non_positive_reference_magnification_but_ffp_ignores_it() {
let mut sfp = sfp_two_mil_at_ten();
sfp.reference_magnification = 0.0;
assert!(matches!(
hold_point_in_reticle(1.0, 0.0, 10.0, &sfp),
Err(ReticleError::NonPositiveReferenceMagnification { .. })
));
let mut ffp = ffp_ladder();
ffp.reference_magnification = 0.0;
assert!(
hold_point_in_reticle(1.0, 0.0, 10.0, &ffp).is_ok(),
"FFP never consults the reference magnification"
);
}
#[test]
fn rejects_empty_and_non_finite_descriptions() {
let mut reticle = ffp_ladder();
reticle.marks.clear();
assert_eq!(
hold_point_in_reticle(1.0, 0.0, 10.0, &reticle),
Err(ReticleError::NoMarks)
);
let mut reticle = ffp_ladder();
reticle.marks[2].down_mil = f64::NAN;
assert_eq!(
hold_point_in_reticle(1.0, 0.0, 10.0, &reticle),
Err(ReticleError::NonFiniteMark { index: 2 })
);
let reticle = ffp_ladder();
assert!(matches!(
hold_point_in_reticle(f64::NAN, 0.0, 10.0, &reticle),
Err(ReticleError::NonFiniteHold { .. })
));
}
#[test]
fn mil_grid_generates_a_cross_not_a_filled_grid() {
let reticle = ReticleDescription::mil_grid(0.5, 2.0).unwrap();
assert_eq!(reticle.marks.len(), 1 + 4 * 4);
assert_eq!(reticle.marks[0].kind, MarkKind::Center);
for mark in &reticle.marks[1..] {
assert!(
mark.down_mil == 0.0 || mark.right_mil == 0.0,
"mil_grid must not produce off-axis marks"
);
}
assert_eq!(reticle.focal_plane, FocalPlane::First);
}
#[test]
fn tree_widens_one_step_per_row() {
let reticle = ReticleDescription::tree(3, 1.0, 0.5).unwrap();
assert_eq!(reticle.marks.len(), 1 + 3 + 2 * (1 + 2 + 3));
let widest_in_row = |down: f64| {
reticle
.marks
.iter()
.filter(|m| m.down_mil == down)
.map(|m| m.right_mil.abs())
.fold(0.0_f64, f64::max)
};
assert_eq!(widest_in_row(1.0), 0.5);
assert_eq!(widest_in_row(2.0), 1.0);
assert_eq!(widest_in_row(3.0), 1.5);
}
#[test]
fn bdc_from_drops_is_pure_data_assembly() {
let reticle =
ReticleDescription::bdc_from_drops(&[(300.0, 1.2), (400.0, 2.4), (500.0, 4.1)]).unwrap();
assert_eq!(reticle.marks.len(), 4);
assert_eq!(reticle.marks[0].kind, MarkKind::Center);
assert_eq!(reticle.marks[1].down_mil, 1.2);
assert_eq!(reticle.marks[1].label.as_deref(), Some("300 m"));
assert_eq!(reticle.marks[3].down_mil, 4.1);
assert!(reticle.marks.iter().all(|m| m.right_mil == 0.0));
}
#[test]
fn generators_reject_bad_parameters() {
assert!(ReticleDescription::mil_grid(0.0, 5.0).is_err());
assert!(ReticleDescription::mil_grid(0.5, 0.0).is_err());
assert!(ReticleDescription::mil_grid(5.0, 1.0).is_err());
assert!(ReticleDescription::tree(0, 1.0, 0.5).is_err());
assert!(ReticleDescription::tree(3, -1.0, 0.5).is_err());
assert!(ReticleDescription::bdc_from_drops(&[]).is_err());
assert!(ReticleDescription::bdc_from_drops(&[(0.0, 1.0)]).is_err());
assert!(ReticleDescription::bdc_from_drops(&[(100.0, f64::NAN)]).is_err());
assert!(matches!(
ReticleDescription::mil_grid(0.001, 10.0),
Err(ReticleError::TooManyMarks { .. })
));
}
#[test]
fn generator_sizes_cannot_overflow_past_the_cap() {
let huge = (usize::MAX / 2) as f64; assert!(matches!(
ReticleDescription::mil_grid(1.0, huge),
Err(ReticleError::TooManyMarks { .. })
));
assert!(matches!(
ReticleDescription::tree(usize::MAX / 2, 1.0, 0.5),
Err(ReticleError::TooManyMarks { .. })
));
assert!(matches!(
ReticleDescription::tree(1000, 1.0, 0.5),
Err(ReticleError::TooManyMarks { .. })
));
}
#[test]
fn description_round_trips_through_serde() {
let reticle = ReticleDescription {
name: "round trip".to_string(),
focal_plane: FocalPlane::Second,
reference_magnification: 12.0,
marks: vec![
ReticleMark::new(0.0, 0.0, MarkKind::Center),
ReticleMark::labeled(3.5, -1.5, MarkKind::Dot, "600 yd"),
ReticleMark::new(6.0, 0.0, MarkKind::Post),
],
};
let json = serde_json::to_string(&reticle).unwrap();
assert!(json.contains("\"sfp\""), "focal plane serializes as sfp: {json}");
assert!(json.contains("\"center\""));
let back: ReticleDescription = serde_json::from_str(&json).unwrap();
assert_eq!(back, reticle);
let json = serde_json::to_string(&ReticleMark::new(1.0, 0.0, MarkKind::Hash)).unwrap();
assert!(!json.contains("label"), "{json}");
let permissive: ReticleDescription = serde_json::from_str(
r#"{"name":"x","focal_plane":"ffp","reference_magnification":1.0,
"marks":[{"down_mil":1.0,"right_mil":0.0,"kind":"hash"}],"stroke":"thin"}"#,
)
.unwrap();
assert_eq!(permissive.marks.len(), 1);
}
#[test]
fn hold_round_trips_through_serde() {
let reticle = ffp_ladder();
let hold = hold_point_in_reticle(3.1, 0.6, 10.0, &reticle).unwrap();
let back: ReticleHold = serde_json::from_str(&serde_json::to_string(&hold).unwrap()).unwrap();
assert_eq!(back, hold);
}
#[test]
fn formatters_are_stable_and_json_is_the_schema() {
let reticle = ReticleDescription::bdc_from_drops(&[(300.0, 1.2)]).unwrap();
let json = format_reticle_description(&reticle, ReticleFormat::Json);
assert!(json.ends_with('\n'));
let back: ReticleDescription = serde_json::from_str(&json).unwrap();
assert_eq!(back, reticle, "generate -o json feeds hold --reticle-json");
let hold = hold_point_in_reticle(1.2, 0.0, 10.0, &reticle).unwrap();
let table = format_reticle_hold(&hold, &reticle, 10.0, ReticleFormat::Table);
assert!(table.contains("Hold down:"));
assert!(table.contains("300 m"));
assert!(!table.contains("Subtension scale"), "FFP hides SFP-only rows");
let sfp = sfp_two_mil_at_ten();
let hold = hold_point_in_reticle(4.0, 0.0, 5.0, &sfp).unwrap();
let table = format_reticle_hold(&hold, &sfp, 5.0, ReticleFormat::Table);
assert!(table.contains("Subtension scale: 2.0000x"));
}
}