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";
fn hash_ladder(
name: String,
spacing_mil: f64,
extent_mil: f64,
) -> Result<ReticleDescription, ReticleError> {
let mut description = ReticleDescription::mil_grid(spacing_mil, extent_mil)?;
description.name = name;
Ok(description)
}
const MIL_PER_MOA: f64 = 1000.0 / 3438.0;
pub const MIL_HASH_HALF_ID: &str = "mil-hash-0.5";
pub const MIL_HASH_FINE_ID: &str = "mil-hash-0.2";
pub const MOA_HASH_1_ID: &str = "moa-hash-1";
pub const MOA_HASH_2_ID: &str = "moa-hash-2";
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.",
},
CatalogEntry {
id: MIL_HASH_HALF_ID,
display_name: "Mil hash, 0.5 mil",
source: "Generic geometry, not a vendor design: hash marks on 0.5 mil \
centres along both stadia. Stated from the spacing alone — \
nothing here is transcribed from anyone's subtension sheet.",
notes: "Many scopes carry a plain half-mil ladder under a vendor name. \
Use this when yours is simply a ruler; if it has a tree, \
irregular windage marks or labelled BDC marks, it is not this \
and the holds will name the wrong mark.",
},
CatalogEntry {
id: MIL_HASH_FINE_ID,
display_name: "Mil hash, 0.2 mil",
source: "Generic geometry, not a vendor design: hash marks on 0.2 mil \
centres along both stadia.",
notes: "A fine ladder, typical of a first-focal-plane target reticle at \
high magnification. Same caveat as the half-mil ladder: this is \
a ruler, not a tree.",
},
CatalogEntry {
id: MOA_HASH_1_ID,
display_name: "MOA hash, 1 MOA",
source: "Generic geometry, not a vendor design: hash marks on 1 MOA \
centres along both stadia, converted at the engine's locked \
printed-table 3438 MOA per radian (adjustment.rs, MBA-724) so \
the marks agree with the MOA column beside them.",
notes: "An MOA ladder is a ladder whose marks fall on MOA multiples; \
the model is milliradians either way. 1 MOA is about 0.29 mil, \
so these sit closer together than a mil ladder's.",
},
CatalogEntry {
id: MOA_HASH_2_ID,
display_name: "MOA hash, 2 MOA",
source: "Generic geometry, not a vendor design: hash marks on 2 MOA \
centres along both stadia, on the same locked 3438 constant.",
notes: "The coarser of the two MOA ladders, and the commoner one on a \
hunting scope.",
},
]
}
pub fn by_id(id: &str) -> Option<Result<ReticleDescription, ReticleError>> {
match id {
MIL_DOT_ID => Some(mil_dot(MIL_DOT_DEFAULT_DOTS_PER_SIDE)),
MIL_HASH_HALF_ID => Some(hash_ladder("Mil hash 0.5".into(), 0.5, 10.0)),
MIL_HASH_FINE_ID => Some(hash_ladder("Mil hash 0.2".into(), 0.2, 5.0)),
MOA_HASH_1_ID => Some(hash_ladder(
"MOA hash 1".into(),
MIL_PER_MOA,
MIL_PER_MOA * 30.0,
)),
MOA_HASH_2_ID => Some(hash_ladder(
"MOA hash 2".into(),
MIL_PER_MOA * 2.0,
MIL_PER_MOA * 30.0,
)),
_ => 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 a_moa_ladder_falls_on_moa_multiples() {
let reticle = by_id(MOA_HASH_1_ID).unwrap().unwrap();
let one_moa = 1000.0 / 3438.0;
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());
for (index, mil) in down.iter().enumerate() {
let expected = one_moa * (index + 1) as f64;
assert!(
(mil - expected).abs() < 1e-9,
"mark {index} at {mil} mil is not {} MOA",
index + 1
);
}
assert!(
down.len() >= 20,
"a 30 MOA ladder should carry 30 marks a side"
);
}
#[test]
fn every_ladder_is_a_cross_and_carries_its_centre() {
for entry in catalog() {
let reticle = by_id(entry.id).unwrap().unwrap();
assert!(
reticle
.marks
.iter()
.any(|m| m.kind == MarkKind::Center && m.down_mil == 0.0 && m.right_mil == 0.0),
"{} has no centre",
entry.id
);
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",
entry.id,
mark.down_mil,
mark.right_mil
);
}
}
}
#[test]
fn the_generic_ladders_say_they_are_generic() {
for id in [
MIL_HASH_HALF_ID,
MIL_HASH_FINE_ID,
MOA_HASH_1_ID,
MOA_HASH_2_ID,
] {
let entry = catalog().into_iter().find(|e| e.id == id).unwrap();
assert!(
entry
.source
.contains("Generic geometry, not a vendor design"),
"{id} does not say it is generic"
);
}
}
#[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());
}
}