use serde::de::{self, SeqAccess, Visitor};
use serde::{Deserialize, Deserializer};
use std::fmt;
use crate::reticle::{
FocalPlane, MarkKind, ReticleDescription, ReticleError, ReticleMark, MAX_RETICLE_MARKS,
};
pub const MOA_TO_MIL: f64 = 0.2908882;
pub const DEFAULT_TEXT_BIND_MIL: f64 = 1.0;
pub fn import_ventum_reticle(json: &str) -> Result<ReticleDescription, ReticleError> {
let reticle: VentumReticle =
serde_json::from_str(json).map_err(|e| ReticleError::InvalidSpec(e.to_string()))?;
let scale = match reticle.unit {
Unit::Mil => 1.0,
Unit::Moa => MOA_TO_MIL,
};
let instances = expand_elements(&reticle.spec.0, scale)?;
let mut marks: Vec<ReticleMark> = Vec::new();
let mut texts: Vec<TextLabel> = Vec::new();
for instance in instances {
match instance.role {
Role::Dot => marks.push(ReticleMark::new(instance.down, instance.right, MarkKind::Dot)),
Role::Tick => {
marks.push(ReticleMark::new(instance.down, instance.right, MarkKind::Hash))
}
Role::Text(label) => texts.push(TextLabel {
down: instance.down,
right: instance.right,
label,
}),
}
}
bind_text_labels(&mut marks, &texts);
let reference_magnification = match reticle.plane {
FocalPlane::Second => reticle.ref_magnification,
FocalPlane::First => 1.0,
};
Ok(ReticleDescription {
name: reticle.name,
focal_plane: reticle.plane,
reference_magnification,
marks,
})
}
struct TextLabel {
down: f64,
right: f64,
label: String,
}
fn bind_text_labels(marks: &mut [ReticleMark], texts: &[TextLabel]) {
for text in texts {
let mut nearest: Option<usize> = None;
let mut nearest_distance = f64::INFINITY;
for (index, mark) in marks.iter().enumerate() {
let distance = ((text.right - mark.right_mil).powi(2)
+ (text.down - mark.down_mil).powi(2))
.sqrt();
if distance < nearest_distance {
nearest_distance = distance;
nearest = Some(index);
}
}
if let Some(index) = nearest {
if nearest_distance <= DEFAULT_TEXT_BIND_MIL {
marks[index].label = Some(text.label.clone());
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum Role {
Dot,
Tick,
Text(String),
}
#[derive(Debug, Clone, PartialEq)]
struct ExpandedInstance {
down: f64,
right: f64,
role: Role,
}
enum PointRole<'a> {
Dot,
Tick,
Text(&'a str),
}
impl PointRole<'_> {
fn role_with_label(&self, ladder_label: Option<String>) -> Role {
match self {
PointRole::Dot => Role::Dot,
PointRole::Tick => Role::Tick,
PointRole::Text(base) => Role::Text(ladder_label.unwrap_or_else(|| (*base).to_string())),
}
}
fn ladder_label(&self, repeat: &Repeat, i: u32) -> Option<String> {
match self {
PointRole::Text(_) if repeat.label => {
Some(format_label_number(repeat.label_start + f64::from(i) * repeat.label_step))
}
_ => None,
}
}
}
fn expand_elements(elements: &[Element], scale: f64) -> Result<Vec<ExpandedInstance>, ReticleError> {
let mut out: Vec<ExpandedInstance> = Vec::new();
for element in elements {
let (x, y, repeat, base) = match element {
Element::Dot { x, y, repeat } => (*x, *y, repeat.as_ref(), PointRole::Dot),
Element::Tick { x, y, repeat } => (*x, *y, repeat.as_ref(), PointRole::Tick),
Element::Text {
x, y, text, repeat,
} => (*x, *y, repeat.as_ref(), PointRole::Text(text)),
Element::Line {}
| Element::Circle {}
| Element::Rect {}
| Element::Grid {}
| Element::Unknown => continue,
};
expand_point(x, y, repeat, scale, base, &mut out)?;
}
Ok(out)
}
fn expand_point(
x: f64,
y: f64,
repeat: Option<&Repeat>,
scale: f64,
base: PointRole<'_>,
out: &mut Vec<ExpandedInstance>,
) -> Result<(), ReticleError> {
let Some(repeat) = repeat else {
let role = base.role_with_label(None);
return push_capped(y * scale, x * scale, role, out);
};
for i in 0..repeat.n {
let offset = f64::from(i) * repeat.step;
let (cx, cy) = match repeat.axis {
Axis::X => (x + offset, y),
Axis::Y => (x, y + offset),
};
let stepped = match repeat.axis {
Axis::X => cx,
Axis::Y => cy,
};
let down = cy * scale;
let right = cx * scale;
let role = base.role_with_label(base.ladder_label(repeat, i));
if repeat.mirror && stepped != 0.0 {
let (mirror_down, mirror_right) = match repeat.axis {
Axis::X => (down, -right),
Axis::Y => (-down, right),
};
push_capped(down, right, role.clone(), out)?;
push_capped(mirror_down, mirror_right, role, out)?;
} else {
push_capped(down, right, role, out)?;
}
}
Ok(())
}
fn push_capped(
down: f64,
right: f64,
role: Role,
out: &mut Vec<ExpandedInstance>,
) -> Result<(), ReticleError> {
if out.len() >= MAX_RETICLE_MARKS {
return Err(ReticleError::TooManyMarks {
count: out.len() + 1,
max: MAX_RETICLE_MARKS,
});
}
out.push(ExpandedInstance { down, right, role });
Ok(())
}
fn format_label_number(value: f64) -> String {
if value.is_finite() && value == value.round() {
(value as i64).to_string()
} else {
value.to_string()
}
}
#[derive(Debug, Deserialize)]
struct VentumReticle {
#[serde(default)]
name: String,
#[serde(default)]
plane: FocalPlane,
#[serde(default = "default_ref_magnification")]
ref_magnification: f64,
#[serde(default)]
unit: Unit,
#[serde(default)]
spec: Spec,
}
fn default_ref_magnification() -> f64 {
1.0
}
#[derive(Debug, Clone, Copy, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Unit {
#[default]
Mil,
Moa,
}
#[derive(Debug, Default)]
struct Spec(Vec<Element>);
impl<'de> Deserialize<'de> for Spec {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct SpecVisitor;
impl<'de> Visitor<'de> for SpecVisitor {
type Value = Spec;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a JSON array of reticle elements, or a string containing that array")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
let elements: Vec<Element> =
serde_json::from_str(v).map_err(de::Error::custom)?;
Ok(Spec(elements))
}
fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
let elements =
Vec::<Element>::deserialize(de::value::SeqAccessDeserializer::new(seq))?;
Ok(Spec(elements))
}
}
deserializer.deserialize_any(SpecVisitor)
}
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Element {
Dot {
x: f64,
y: f64,
#[serde(default)]
repeat: Option<Repeat>,
},
Tick {
x: f64,
y: f64,
#[serde(default)]
repeat: Option<Repeat>,
},
Text {
x: f64,
y: f64,
text: String,
#[serde(default)]
repeat: Option<Repeat>,
},
Line {},
Circle {},
Rect {},
Grid {},
#[serde(other)]
Unknown,
}
#[derive(Debug, Deserialize)]
struct Repeat {
axis: Axis,
step: f64,
n: u32,
#[serde(default)]
mirror: bool,
#[serde(default)]
label: bool,
#[serde(rename = "labelStart", default)]
label_start: f64,
#[serde(rename = "labelStep", default = "default_label_step")]
label_step: f64,
}
fn default_label_step() -> f64 {
1.0
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Axis {
X,
Y,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::reticle::hold_point_in_reticle;
const MBR_SPEC: &str = r#"[{"type":"dot","y":4,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":9,"mirror":true}},{"type":"dot","y":8,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":13,"mirror":true}},{"type":"dot","y":12,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":17,"mirror":true}}]"#;
fn row_rights(desc: &ReticleDescription, down: f64) -> Vec<f64> {
let mut rights: Vec<f64> = desc
.marks
.iter()
.filter(|m| m.down_mil == down)
.map(|m| m.right_mil)
.collect();
rights.sort_by(|a, b| a.partial_cmp(b).unwrap());
rights
}
fn symmetric_spread(max: i32) -> Vec<f64> {
let mut v: Vec<f64> = Vec::new();
for k in 1..=max {
v.push(-f64::from(k));
}
for k in 1..=max {
v.push(f64::from(k));
}
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
v
}
fn assert_mbr(desc: &ReticleDescription) {
assert_eq!(desc.marks.len(), 78, "MBR expands to 78 holdable marks");
assert_eq!(row_rights(desc, 4.0), symmetric_spread(9), "row down=4 -> ±1..±9");
assert_eq!(row_rights(desc, 8.0), symmetric_spread(13), "row down=8 -> ±1..±13");
assert_eq!(row_rights(desc, 12.0), symmetric_spread(17), "row down=12 -> ±1..±17");
assert_eq!(row_rights(desc, 4.0).len(), 18);
assert_eq!(row_rights(desc, 8.0).len(), 26);
assert_eq!(row_rights(desc, 12.0).len(), 34);
assert!(desc.marks.iter().all(|m| m.kind == MarkKind::Dot), "all MBR marks are dots");
assert!(
desc.marks.iter().all(|m| m.right_mil != 0.0),
"no MBR mark lands on the center line"
);
assert!(
row_rights(desc, 4.0).iter().all(|&r| r.abs() <= 9.0),
"row down=4 has nothing beyond ±9"
);
}
#[test]
fn mbr_array_form_expands_to_the_full_dot_tree() {
let json = format!(r#"{{"name":"MBR","plane":"ffp","unit":"mil","spec":{MBR_SPEC}}}"#);
let desc = import_ventum_reticle(&json).unwrap();
assert_eq!(desc.name, "MBR");
assert_eq!(desc.focal_plane, FocalPlane::First);
assert_mbr(&desc);
}
#[test]
fn mbr_string_form_parses_identically_to_the_array_form() {
let string_form = serde_json::json!({
"name": "MBR", "plane": "ffp", "unit": "mil", "spec": MBR_SPEC
})
.to_string();
let array_form = format!(r#"{{"name":"MBR","plane":"ffp","unit":"mil","spec":{MBR_SPEC}}}"#);
let from_string = import_ventum_reticle(&string_form).unwrap();
let from_array = import_ventum_reticle(&array_form).unwrap();
assert_mbr(&from_string);
assert_eq!(
from_string, from_array,
"the string-wrapped spec must import identically to the array spec"
);
}
#[test]
fn sfp_carries_reference_magnification_but_ffp_normalizes_it() {
let sfp = import_ventum_reticle(
r#"{"name":"SE","plane":"sfp","ref_magnification":6,"unit":"mil",
"spec":[{"type":"dot","x":0,"y":1}]}"#,
)
.unwrap();
assert_eq!(sfp.focal_plane, FocalPlane::Second);
assert_eq!(sfp.reference_magnification, 6.0);
let ffp = import_ventum_reticle(
r#"{"name":"F","plane":"ffp","ref_magnification":6,"unit":"mil",
"spec":[{"type":"dot","x":0,"y":1}]}"#,
)
.unwrap();
assert_eq!(ffp.focal_plane, FocalPlane::First);
assert_eq!(ffp.reference_magnification, 1.0);
}
#[test]
fn moa_coordinates_convert_to_milliradians() {
let desc = import_ventum_reticle(
r#"{"name":"M","unit":"moa","spec":[{"type":"dot","x":0,"y":2}]}"#,
)
.unwrap();
assert_eq!(desc.marks.len(), 1);
assert!(
(desc.marks[0].down_mil - 2.0 * MOA_TO_MIL).abs() < 1e-6,
"2 MOA -> {} mil (~0.581776)",
desc.marks[0].down_mil
);
}
#[test]
fn text_binds_to_the_nearest_mark_and_far_text_is_dropped() {
let desc = import_ventum_reticle(
r#"{"name":"T","unit":"mil","spec":[
{"type":"tick","x":0,"y":5},
{"type":"text","x":0.5,"y":5,"text":"5"},
{"type":"text","x":10,"y":10,"text":"far"}
]}"#,
)
.unwrap();
assert_eq!(desc.marks.len(), 1);
assert_eq!(desc.marks[0].down_mil, 5.0);
assert_eq!(desc.marks[0].kind, MarkKind::Hash);
assert_eq!(desc.marks[0].label.as_deref(), Some("5"));
assert!(
desc.marks.iter().all(|m| m.label.as_deref() != Some("far")),
"distant text must not bind"
);
}
#[test]
fn mirror_never_duplicates_a_center_mark() {
let center = import_ventum_reticle(
r#"{"name":"C","unit":"mil","spec":[
{"type":"tick","x":0,"y":1,"repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
]}"#,
)
.unwrap();
assert_eq!(center.marks.len(), 1, "no duplicate at the center");
assert_eq!(center.marks[0].right_mil, 0.0);
let off = import_ventum_reticle(
r#"{"name":"O","unit":"mil","spec":[
{"type":"tick","x":1,"y":1,"repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
]}"#,
)
.unwrap();
assert_eq!(off.marks.len(), 2, "mirror of an off-center mark yields ±1");
let mut rights: Vec<f64> = off.marks.iter().map(|m| m.right_mil).collect();
rights.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert_eq!(rights, vec![-1.0, 1.0]);
}
#[test]
fn text_ladder_auto_numbers_each_copy() {
let elements = vec![Element::Text {
x: 0.0,
y: 1.0,
text: "base".to_string(),
repeat: Some(Repeat {
axis: Axis::Y,
step: 1.0,
n: 3,
mirror: false,
label: true,
label_start: 0.0,
label_step: 1.0,
}),
}];
let instances = expand_elements(&elements, 1.0).unwrap();
assert_eq!(
instances,
vec![
ExpandedInstance { down: 1.0, right: 0.0, role: Role::Text("0".to_string()) },
ExpandedInstance { down: 2.0, right: 0.0, role: Role::Text("1".to_string()) },
ExpandedInstance { down: 3.0, right: 0.0, role: Role::Text("2".to_string()) },
]
);
}
#[test]
fn label_numbers_print_integers_without_a_decimal() {
assert_eq!(format_label_number(5.0), "5");
assert_eq!(format_label_number(0.0), "0");
assert_eq!(format_label_number(-3.0), "-3");
assert_eq!(format_label_number(5.5), "5.5");
}
#[test]
fn a_runaway_repeat_is_capped_promptly() {
let json = r#"{"name":"X","unit":"mil","spec":[
{"type":"dot","x":0,"y":1,"repeat":{"axis":"x","step":1,"n":100000}}
]}"#;
assert!(matches!(
import_ventum_reticle(json),
Err(ReticleError::TooManyMarks { .. })
));
}
#[test]
fn malformed_json_reports_invalid_spec() {
assert!(matches!(
import_ventum_reticle("{not json"),
Err(ReticleError::InvalidSpec(_))
));
assert!(matches!(
import_ventum_reticle(r#"{"name":"X","spec":"not-an-array"}"#),
Err(ReticleError::InvalidSpec(_))
));
}
#[test]
fn decoration_and_unknown_elements_are_dropped() {
let desc = import_ventum_reticle(
r#"{"name":"D","unit":"mil","spec":[
{"type":"line","x1":-5,"y1":0,"x2":5,"y2":0,"width":0.05},
{"type":"circle","x":0,"y":0,"r":1.0},
{"type":"rect","x":-1,"y":-1,"w":2,"h":2},
{"type":"grid","x0":-5,"y0":-5,"x1":5,"y1":5,"step":1},
{"type":"future_shape","x":0,"y":0},
{"type":"dot","x":0,"y":2,"color":"red","width":0.1}
]}"#,
)
.unwrap();
assert_eq!(desc.marks.len(), 1);
assert_eq!(desc.marks[0].down_mil, 2.0);
assert_eq!(desc.marks[0].kind, MarkKind::Dot);
}
#[test]
fn imported_mbr_reticle_hold_solves() {
let json = format!(r#"{{"name":"MBR","plane":"ffp","unit":"mil","spec":{MBR_SPEC}}}"#);
let desc = import_ventum_reticle(&json).unwrap();
let hold = hold_point_in_reticle(4.0, 2.0, 1.0, &desc).unwrap();
assert!(hold.nearest_mark.is_some());
assert!(
hold.nearest_mark_distance_mil < 0.5,
"hold should sit on/near a real mark (distance {})",
hold.nearest_mark_distance_mil
);
assert!(!hold.off_reticle, "a mid-tree hold is on the reticle");
}
}