use crate::reticle::{FocalPlane, MarkKind, ReticleDescription, ReticleError, ReticleMark};
#[derive(Debug, Clone, PartialEq)]
pub struct CatalogEntry {
pub id: &'static str,
pub display_name: &'static str,
pub source: &'static str,
pub notes: &'static str,
}
pub fn mil_dot(dots_per_side: usize) -> Result<ReticleDescription, ReticleError> {
if dots_per_side == 0 {
return Err(ReticleError::InvalidGeneratorParameter {
parameter: "dots_per_side",
value: 0.0,
rule: "at least one dot on each arm",
});
}
let mut marks = Vec::with_capacity(1 + dots_per_side * 4);
marks.push(ReticleMark::new(0.0, 0.0, MarkKind::Center));
for n in 1..=dots_per_side {
let mil = n as f64;
marks.push(ReticleMark::labeled(
mil,
0.0,
MarkKind::Dot,
format!("{n} mil down"),
));
marks.push(ReticleMark::labeled(
-mil,
0.0,
MarkKind::Dot,
format!("{n} mil up"),
));
marks.push(ReticleMark::labeled(
0.0,
mil,
MarkKind::Dot,
format!("{n} mil right"),
));
marks.push(ReticleMark::labeled(
0.0,
-mil,
MarkKind::Dot,
format!("{n} mil left"),
));
}
Ok(ReticleDescription {
name: format!("Mil-Dot ({dots_per_side} per side)"),
focal_plane: FocalPlane::First,
reference_magnification: 1.0,
marks,
})
}
pub const MIL_DOT_DEFAULT_DOTS_PER_SIDE: usize = 5;
pub const MIL_DOT_ID: &str = "mil-dot";
pub fn catalog() -> Vec<CatalogEntry> {
vec![CatalogEntry {
id: MIL_DOT_ID,
display_name: "Mil-Dot",
source: "US military mil-dot standard: dots on 1 mil centres, 0.2 mil dot \
subtension. Long-published and carried unchanged by every scope that \
names the reticle; no vendor sheet is involved.",
notes: "The 1 mil spacing is the standard and does not vary. How many dots are \
etched on each arm DOES vary between scopes (four and five are both \
common); this builds five, and a hold inside the ladder reads the same \
either way. Dots subtend 0.2 mil, which this model does not draw — marks \
are positions, and a hold is measured to a mark's centre.",
}]
}
pub fn by_id(id: &str) -> Option<Result<ReticleDescription, ReticleError>> {
match id {
MIL_DOT_ID => Some(mil_dot(MIL_DOT_DEFAULT_DOTS_PER_SIDE)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::reticle::hold_point_in_reticle;
#[test]
fn mil_dot_puts_its_dots_on_one_mil_centres() {
let reticle = mil_dot(5).unwrap();
let mut down: Vec<f64> = reticle
.marks
.iter()
.filter(|m| m.right_mil == 0.0 && m.down_mil > 0.0)
.map(|m| m.down_mil)
.collect();
down.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert_eq!(down, vec![1.0, 2.0, 3.0, 4.0, 5.0]);
}
#[test]
fn it_is_a_cross_not_a_grid() {
let reticle = mil_dot(5).unwrap();
for mark in &reticle.marks {
assert!(
mark.down_mil == 0.0 || mark.right_mil == 0.0,
"mark at ({}, {}) is off both stadia — that is a grid",
mark.down_mil,
mark.right_mil
);
}
assert_eq!(reticle.marks.len(), 1 + 5 * 4);
}
#[test]
fn a_four_dot_scope_holds_the_same_as_a_five_dot_one() {
let four = mil_dot(4).unwrap();
let five = mil_dot(5).unwrap();
for reticle in [&four, &five] {
let hold = hold_point_in_reticle(3.0, 0.0, 10.0, reticle).unwrap();
let mark = &reticle.marks[hold.nearest_mark.unwrap()];
assert_eq!(mark.down_mil, 3.0);
assert_eq!(hold.nearest_mark_distance_mil, 0.0);
}
}
#[test]
fn every_entry_names_a_source_and_builds() {
for entry in catalog() {
assert!(
!entry.source.trim().is_empty(),
"{} names no source",
entry.id
);
assert!(
!entry.notes.trim().is_empty(),
"{} carries no notes",
entry.id
);
assert!(!entry.display_name.trim().is_empty());
assert_eq!(entry.id, entry.id.to_lowercase(), "ids are lowercase");
let built = by_id(entry.id)
.unwrap_or_else(|| panic!("{} is listed but by_id does not build it", entry.id))
.unwrap_or_else(|e| panic!("{} does not build: {e}", entry.id));
built
.validate()
.unwrap_or_else(|e| panic!("{} builds an invalid reticle: {e}", entry.id));
}
}
#[test]
fn an_unknown_id_is_none_rather_than_a_substitute() {
assert!(by_id("no-such-reticle").is_none());
assert!(by_id("").is_none());
assert!(
by_id("MIL-DOT").is_none(),
"ids are case-sensitive on the wire"
);
}
#[test]
fn zero_dots_is_refused() {
assert!(mil_dot(0).is_err());
}
}