use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ClickBase {
Mil,
Moa,
Smoa,
}
pub fn adjustment_factor(base: ClickBase) -> f64 {
match base {
ClickBase::Mil => 1000.0,
ClickBase::Moa => 3438.0,
ClickBase::Smoa => 3600.0,
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ClickValue {
pub size: f64,
pub base: ClickBase,
}
pub fn parse_click_value(s: &str) -> Result<ClickValue, String> {
let t = s.trim().to_ascii_lowercase();
let (num, base) = if let Some(n) = t.strip_suffix("smoa") {
(n, ClickBase::Smoa)
} else if let Some(n) = t.strip_suffix("iphy") {
(n, ClickBase::Smoa)
} else if let Some(n) = t.strip_suffix("moa") {
(n, ClickBase::Moa)
} else if let Some(n) = t.strip_suffix("mil") {
(n, ClickBase::Mil)
} else {
return Err(format!(
"click value '{s}' needs a unit suffix: mil, moa, smoa, or iphy (e.g. 0.1mil, 0.25moa)"
));
};
let size: f64 = num
.trim()
.parse()
.map_err(|_| format!("click value '{s}' has an invalid number '{num}'"))?;
if !size.is_finite() || size <= 0.0 {
return Err(format!(
"click value '{s}' must be a positive, finite graduation"
));
}
Ok(ClickValue { size, base })
}
impl Serialize for ClickValue {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let suffix = match self.base {
ClickBase::Mil => "mil",
ClickBase::Moa => "moa",
ClickBase::Smoa => "smoa",
};
let size = self.size;
serializer.serialize_str(&format!("{size}{suffix}"))
}
}
impl<'de> Deserialize<'de> for ClickValue {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
parse_click_value(&raw).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Quantized {
pub clicks: i64,
pub residual: f64,
}
pub fn quantize_angle(angle: f64, click: &ClickValue) -> Quantized {
let clicks_f = (angle / click.size).round();
Quantized {
clicks: clicks_f as i64,
residual: angle - clicks_f * click.size,
}
}
pub fn click_size_mil(click: &ClickValue) -> f64 {
click.size * adjustment_factor(ClickBase::Mil) / adjustment_factor(click.base)
}
pub fn clicks_for(drop_yd: f64, range_yd: f64, click: &ClickValue) -> i64 {
if range_yd < 1.0 {
return 0;
}
let angle = (drop_yd / range_yd) * adjustment_factor(click.base);
quantize_angle(angle, click).clicks
}
pub fn zero_banner_dial_values(angle_deg: f64, angle_rad: f64, elevation_cf: f64) -> (f64, f64) {
(
angle_deg * 60.0 / elevation_cf,
angle_rad * 1000.0 / elevation_cf,
)
}
pub fn tracking_cf_in_range(value: f64) -> bool {
value.is_finite() && value > 0.5 && value < 1.5
}
#[cfg(test)]
#[allow(clippy::items_after_test_module)] mod tests {
use super::*;
#[test]
fn zero_banner_dial_values_divide_by_the_elevation_cf_and_are_exact_at_one() {
let deg = 0.0717_f64;
let rad = deg.to_radians();
let (moa, mrad) = zero_banner_dial_values(deg, rad, 1.0);
assert_eq!(moa.to_bits(), (deg * 60.0).to_bits());
assert_eq!(mrad.to_bits(), (rad * 1000.0).to_bits());
let (moa_cf, mrad_cf) = zero_banner_dial_values(deg, rad, 0.95);
assert_eq!(moa_cf.to_bits(), (deg * 60.0 / 0.95).to_bits());
assert_eq!(mrad_cf.to_bits(), (rad * 1000.0 / 0.95).to_bits());
assert!(moa_cf > moa && mrad_cf > mrad);
}
#[test]
fn parse_click_value_accepts_suffixed_and_rejects_bare() {
let c = parse_click_value("0.25moa").unwrap();
assert!((c.size - 0.25).abs() < 1e-12);
assert!(matches!(c.base, ClickBase::Moa));
assert!(matches!(
parse_click_value("0.1mil").unwrap().base,
ClickBase::Mil
));
assert!(matches!(
parse_click_value("0.125smoa").unwrap().base,
ClickBase::Smoa
));
assert!(matches!(
parse_click_value("0.125iphy").unwrap().base,
ClickBase::Smoa
));
for bad in ["0.25", "moa", "-0.1mil", "0mil", "0.1mils", ""] {
assert!(parse_click_value(bad).is_err(), "{bad:?} must be rejected");
}
let e = parse_click_value("0.25").unwrap_err();
assert!(e.contains("mil") && e.contains("moa"), "{e}");
}
#[test]
fn clicks_round_to_nearest_whole_click() {
let quarter_moa = parse_click_value("0.25moa").unwrap();
let drop_yd = 2.6 / 3438.0 * 100.0;
assert_eq!(clicks_for(drop_yd, 100.0, &quarter_moa), 10);
assert_eq!(clicks_for(-drop_yd, 100.0, &quarter_moa), -10);
let drop_yd = 2.625 / 3438.0 * 100.0;
assert_eq!(clicks_for(drop_yd, 100.0, &quarter_moa), 11);
}
#[test]
fn quantize_reconstruction_identity_is_bit_exact() {
let c = ClickValue {
size: 0.25,
base: ClickBase::Moa,
};
for angle in [
0.0,
0.24,
0.25,
0.26,
-std::f64::consts::PI,
7.4499999,
1234.567,
] {
let q = quantize_angle(angle, &c);
assert_eq!(
q.clicks as f64 * c.size + q.residual,
angle,
"identity broke at {angle}"
);
assert!(
q.residual.abs() <= c.size / 2.0 + f64::EPSILON,
"residual beyond half-click"
);
}
}
#[test]
fn quantize_exact_multiples_have_residual_exactly_zero() {
let c = ClickValue {
size: 0.1,
base: ClickBase::Mil,
};
let q = quantize_angle(0.1 * 27.0, &c);
assert_eq!(q.clicks, 27);
assert_eq!(q.residual, 0.0); }
#[test]
fn quantize_ties_round_away_from_zero_matching_clicks_for() {
let c = ClickValue {
size: 0.5,
base: ClickBase::Mil,
};
assert_eq!(quantize_angle(0.25, &c).clicks, 1); assert_eq!(quantize_angle(-0.25, &c).clicks, -1);
}
#[test]
fn clicks_for_is_unchanged_and_delegates() {
let c = ClickValue {
size: 0.25,
base: ClickBase::Moa,
};
assert_eq!(clicks_for(10.0, 0.5, &c), 0); let angle = (7.2 / 300.0) * adjustment_factor(ClickBase::Moa);
assert_eq!(
clicks_for(7.2, 300.0, &c),
(angle / 0.25_f64).round() as i64
);
}
#[test]
fn click_size_mil_converts_via_the_locked_factor_table() {
let moa = ClickValue {
size: 0.25,
base: ClickBase::Moa,
};
assert_eq!(click_size_mil(&moa), 0.25 * 1000.0 / 3438.0);
let mil = ClickValue {
size: 0.1,
base: ClickBase::Mil,
};
assert_eq!(click_size_mil(&mil), 0.1);
}
#[test]
fn click_value_json_is_pinned_to_the_suffixed_string() {
let quarter_moa = ClickValue {
size: 0.25,
base: ClickBase::Moa,
};
assert_eq!(serde_json::to_string(&quarter_moa).unwrap(), "\"0.25moa\"");
let tenth_mil = ClickValue {
size: 0.1,
base: ClickBase::Mil,
};
assert_eq!(serde_json::to_string(&tenth_mil).unwrap(), "\"0.1mil\"");
let one_smoa = ClickValue {
size: 1.0,
base: ClickBase::Smoa,
};
assert_eq!(serde_json::to_string(&one_smoa).unwrap(), "\"1smoa\"");
}
#[test]
fn click_value_round_trips_through_json_including_smoa() {
let cases = [
ClickValue {
size: 0.1,
base: ClickBase::Mil,
},
ClickValue {
size: 0.25,
base: ClickBase::Moa,
},
ClickValue {
size: 1.0,
base: ClickBase::Smoa,
},
ClickValue {
size: 0.125,
base: ClickBase::Smoa,
},
];
for click in cases {
let json = serde_json::to_string(&click).unwrap();
let parsed: ClickValue = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, click, "round trip broke for {json}");
}
}
#[test]
fn click_value_deserialize_rejects_what_parse_click_value_rejects() {
for bad in ["\"0mil\"", "\"-0.1mil\"", "\"0.25\"", "\"nonsense\""] {
assert!(
serde_json::from_str::<ClickValue>(bad).is_err(),
"{bad} must be rejected"
);
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum AdjustmentUnit {
#[default]
#[serde(alias = "MIL")]
Mil,
#[serde(alias = "MOA")]
Moa,
#[serde(alias = "SMOA")]
Smoa,
#[serde(alias = "IPHY")]
Iphy,
#[serde(alias = "CLICKS")]
Clicks,
}
pub fn drop_to_adjustment(drop_yd: f64, range_yd: f64, unit: AdjustmentUnit) -> f64 {
if range_yd < 1.0 {
return 0.0;
}
let factor = match unit {
AdjustmentUnit::Mil => adjustment_factor(ClickBase::Mil),
AdjustmentUnit::Moa => adjustment_factor(ClickBase::Moa),
AdjustmentUnit::Smoa | AdjustmentUnit::Iphy => adjustment_factor(ClickBase::Smoa),
AdjustmentUnit::Clicks => {
debug_assert!(false, "Clicks must be resolved via clicks_for()");
adjustment_factor(ClickBase::Mil)
}
};
(drop_yd / range_yd) * factor
}
pub fn unit_factor_for_bias(unit: AdjustmentUnit) -> f64 {
match unit {
AdjustmentUnit::Mil => adjustment_factor(ClickBase::Mil),
AdjustmentUnit::Moa => adjustment_factor(ClickBase::Moa),
AdjustmentUnit::Smoa | AdjustmentUnit::Iphy => adjustment_factor(ClickBase::Smoa),
AdjustmentUnit::Clicks => {
debug_assert!(false, "Clicks bias is applied on raw drop, not here");
adjustment_factor(ClickBase::Mil)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AdjustmentDisplay {
pub value: f64,
pub quantized: Option<Quantized>,
}
pub fn adjustment_display(
drop_yd: f64,
range_yd: f64,
unit: AdjustmentUnit,
click: Option<ClickValue>,
bias_mil: f64,
cf: f64,
) -> AdjustmentDisplay {
match click {
Some(c) => {
let biased_drop_yd = if bias_mil != 0.0 {
drop_yd + bias_mil / 1000.0 * range_yd
} else {
drop_yd
};
let drop_for_clicks = biased_drop_yd / cf;
if range_yd < 1.0 {
AdjustmentDisplay {
value: 0.0,
quantized: Some(Quantized {
clicks: 0,
residual: 0.0,
}),
}
} else {
let angle = (drop_for_clicks / range_yd) * adjustment_factor(c.base);
let q = quantize_angle(angle, &c);
AdjustmentDisplay {
value: q.clicks as f64,
quantized: Some(q),
}
}
}
None => {
let true_need = drop_to_adjustment(drop_yd, range_yd, unit);
let biased = if bias_mil != 0.0 {
true_need
+ bias_mil * unit_factor_for_bias(unit) / adjustment_factor(ClickBase::Mil)
} else {
true_need
};
AdjustmentDisplay {
value: biased / cf,
quantized: None,
}
}
}
}
pub fn windage_adjustment_display(
drift_yd: f64,
range_yd: f64,
windage_unit: AdjustmentUnit,
windage_click: Option<ClickValue>,
windage_bias_mil: f64,
windage_cf: f64,
) -> AdjustmentDisplay {
let click = if windage_unit == AdjustmentUnit::Clicks {
windage_click
} else {
None
};
adjustment_display(
drift_yd,
range_yd,
windage_unit,
click,
windage_bias_mil,
windage_cf,
)
}
pub fn adjustment_unit_label(unit: AdjustmentUnit) -> String {
match unit {
AdjustmentUnit::Mil => "MIL".to_string(),
AdjustmentUnit::Moa => "MOA".to_string(),
AdjustmentUnit::Smoa => "SMOA".to_string(),
AdjustmentUnit::Iphy => "IPHY".to_string(),
AdjustmentUnit::Clicks => "CLICKS".to_string(),
}
}