Skip to main content

ballistics_engine/
reticle_import.rs

1//! MBA-1440: import Bero's "Ventum" reticle spec into the engine's [`ReticleDescription`].
2//!
3//! A reticle drawn in Bero's Ventum tool is a single JSON object: reticle-level metadata
4//! (name, focal plane, calibration magnification, angular unit) plus a `spec` list of
5//! drawing elements. This module is the one-way transform from that authoring format into
6//! the engine's own [`ReticleDescription`], so a Ventum reticle can be hold-solved by the
7//! existing [`hold_point_in_reticle`](crate::reticle::hold_point_in_reticle) without any of the CLI/FFI/WASM surfaces having to
8//! learn a second schema. It is a converter, not new physics — every coordinate is carried
9//! straight through, only unit-normalized to milliradians and repeat-expanded.
10//!
11//! # What is kept, what is dropped
12//!
13//! The Ventum `spec` mixes *hold-bearing* marks with *decoration*:
14//!
15//! * `dot` and `tick` are the holdable marks. A `dot` becomes a [`MarkKind::Dot`]; a `tick`
16//!   becomes a [`MarkKind::Hash`] whose hold point is its own `(x, y)` anchor.
17//! * `text` is a standalone label positioned in reticle coordinates. Following the schema's
18//!   recommended "option (b)", each text is bound to the nearest hold-bearing mark within
19//!   [`DEFAULT_TEXT_BIND_MIL`] and becomes that mark's `label`; unattached text is dropped.
20//! * `line`, `circle`, `rect`, `grid`, and any unknown future element type are pure
21//!   decoration and are dropped.
22//!
23//! # Coordinate and unit conventions
24//!
25//! Ventum uses `+x = right`, `+y = down`, origin at the reticle center — identical to the
26//! engine's [`ReticleMark::right_mil`] / [`ReticleMark::down_mil`], so no sign flips are
27//! needed. All coordinates are expressed in the reticle's own `unit`; MOA input is
28//! converted to milliradians with `1 MOA = 0.2908882 mil` ([`MOA_TO_MIL`]). Auto-numbered
29//! ladder labels are NOT unit-converted — a label is the reticle-unit value it names.
30//!
31//! Confirmed by the format's author (2026-08-01): `+y` is down because a reticle is numbered
32//! downward (a 4-unit holdover is `y: 4`; a stadia mark 5 up is `y: -5`), which is plain SVG
33//! screen convention and happens to match shooter intuition.
34//!
35//! ## Arc angles (not yet consumed — recorded so the mapping is not guessed later)
36//!
37//! Ventum draws arcs as a `circle` element carrying `start`/`end` angles. Those angles are
38//! measured from 3 o'clock (`0° = +x`, right) and sweep **CLOCKWISE**:
39//!
40//! ```text
41//!   0° = right      90° = down      180° = left      270° = UP
42//! ```
43//!
44//! The one counterintuitive consequence, worth stating because it is the opposite of a
45//! compass: since `+y` is down, the TOP of the reticle is 270°, not 90°. A horseshoe opening
46//! downward is therefore `start: 200, end: 340`. This is the same system as the coordinates
47//! (`+x` right, `+y` down, 0° at 3 o'clock, clockwise), so the format never mixes conventions.
48//!
49//! A point on an arc of radius `r` about the reticle center is therefore
50//!
51//! ```text
52//!   x = r * cos(θ)          y = r * sin(θ)          (θ in degrees, +y DOWN)
53//! ```
54//!
55//! Note the PLUS on the sine: in a y-up math convention this term is negated, and that single
56//! sign is the whole trap. Verified against the author's reference diagram — its 0/90/180/270
57//! markers and the `start: 200, end: 340` horseshoe endpoints reproduce exactly under the
58//! formula above, and the arc's 140° clockwise sweep passes through 270° (the top), leaving the
59//! gap at the bottom.
60//!
61//! This module currently drops `circle` as decoration (see above), so nothing here depends on
62//! these angles yet; the note exists so that whoever makes arcs hold-bearing does not have to
63//! re-derive the convention.
64//!
65//! # Safety
66//!
67//! The mark cap ([`crate::reticle::MAX_RETICLE_MARKS`]) is enforced *during* repeat
68//! expansion, so a hostile `repeat.n` (or a huge mirrored ladder) can never allocate an
69//! unbounded vector: expansion stops the instant the running instance count would exceed
70//! the cap, having materialized at most one instance past it. This is the same defense as
71//! the 0.31.0 reticle generator size guards.
72
73use serde::de::{self, SeqAccess, Visitor};
74use serde::{Deserialize, Deserializer};
75use std::fmt;
76
77use crate::reticle::{
78    FocalPlane, MarkKind, ReticleDescription, ReticleError, ReticleMark, MAX_RETICLE_MARKS,
79};
80
81/// Milliradians per minute of angle (`1 MOA = 0.2908882 mil`). Ventum reticles authored in
82/// MOA are converted to the engine's milliradian marks with this factor.
83pub const MOA_TO_MIL: f64 = 0.2908882;
84
85/// Largest `(x, y)` distance (in milliradians) at which a `text` element binds to a
86/// hold-bearing mark and becomes its label. Text farther than this from every mark is
87/// treated as free-floating decoration and dropped.
88pub const DEFAULT_TEXT_BIND_MIL: f64 = 1.0;
89
90/// Import a Ventum reticle spec into a [`ReticleDescription`].
91///
92/// `json` is a single Ventum reticle object (see the module documentation for the fields).
93/// The returned description is ready for [`hold_point_in_reticle`](crate::reticle::hold_point_in_reticle): its marks are in
94/// milliradians from the optical center, `focal_plane` mirrors the spec's `plane`, and
95/// `reference_magnification` carries the spec's `ref_magnification` for an SFP reticle
96/// (`1.0` for FFP, where subtensions are magnification-independent).
97///
98/// This is a pure transform: it does not itself reject an empty or all-decoration reticle
99/// (the resulting description simply carries no marks, which [`hold_point_in_reticle`](crate::reticle::hold_point_in_reticle) then
100/// reports as [`ReticleError::NoMarks`]). It DOES enforce
101/// [`crate::reticle::MAX_RETICLE_MARKS`] during expansion.
102///
103/// # Errors
104///
105/// Returns [`ReticleError::InvalidSpec`] if `json` cannot be parsed as a Ventum reticle,
106/// and [`ReticleError::TooManyMarks`] if repeat expansion would exceed the mark cap.
107pub fn import_ventum_reticle(json: &str) -> Result<ReticleDescription, ReticleError> {
108    let reticle: VentumReticle =
109        serde_json::from_str(json).map_err(|e| ReticleError::InvalidSpec(e.to_string()))?;
110
111    let scale = match reticle.unit {
112        Unit::Mil => 1.0,
113        Unit::Moa => MOA_TO_MIL,
114    };
115
116    // Expand every element's `repeat` into concrete, unit-scaled instances first, with the
117    // mark cap enforced during expansion.
118    let instances = expand_elements(&reticle.spec.0, scale)?;
119
120    // Classify: dot/tick become marks; text is collected for nearest-mark binding.
121    let mut marks: Vec<ReticleMark> = Vec::new();
122    let mut texts: Vec<TextLabel> = Vec::new();
123    for instance in instances {
124        match instance.role {
125            Role::Dot => marks.push(ReticleMark::new(instance.down, instance.right, MarkKind::Dot)),
126            Role::Tick => {
127                marks.push(ReticleMark::new(instance.down, instance.right, MarkKind::Hash))
128            }
129            Role::Text(label) => texts.push(TextLabel {
130                down: instance.down,
131                right: instance.right,
132                label,
133            }),
134        }
135    }
136
137    bind_text_labels(&mut marks, &texts);
138
139    // `ref_magnification` is meaningful only for SFP; an FFP reticle's subtensions do not
140    // depend on magnification, so we normalize its reference to 1.0 (as the engine does).
141    let reference_magnification = match reticle.plane {
142        FocalPlane::Second => reticle.ref_magnification,
143        FocalPlane::First => 1.0,
144    };
145
146    Ok(ReticleDescription {
147        name: reticle.name,
148        focal_plane: reticle.plane,
149        reference_magnification,
150        marks,
151    })
152}
153
154/// A `text` element after expansion: its position (already unit-scaled, in milliradians)
155/// and the label string it would apply to the nearest mark.
156struct TextLabel {
157    down: f64,
158    right: f64,
159    label: String,
160}
161
162/// Bind each collected text to the nearest mark within [`DEFAULT_TEXT_BIND_MIL`], setting
163/// that mark's label. Text with no mark inside the cap is dropped. When two texts bind to
164/// the same mark the later one wins.
165fn bind_text_labels(marks: &mut [ReticleMark], texts: &[TextLabel]) {
166    for text in texts {
167        let mut nearest: Option<usize> = None;
168        let mut nearest_distance = f64::INFINITY;
169        for (index, mark) in marks.iter().enumerate() {
170            let distance = ((text.right - mark.right_mil).powi(2)
171                + (text.down - mark.down_mil).powi(2))
172            .sqrt();
173            if distance < nearest_distance {
174                nearest_distance = distance;
175                nearest = Some(index);
176            }
177        }
178        if let Some(index) = nearest {
179            if nearest_distance <= DEFAULT_TEXT_BIND_MIL {
180                marks[index].label = Some(text.label.clone());
181            }
182        }
183    }
184}
185
186/// The role an expanded point instance plays once classified.
187#[derive(Debug, Clone, PartialEq)]
188enum Role {
189    Dot,
190    Tick,
191    /// A text label carrying its (possibly auto-numbered) string.
192    Text(String),
193}
194
195/// One expanded point instance: a hold-bearing mark or a text label, positioned in
196/// milliradians from center (unit scale already applied).
197#[derive(Debug, Clone, PartialEq)]
198struct ExpandedInstance {
199    /// Milliradians below the optical center.
200    down: f64,
201    /// Milliradians right of the optical center.
202    right: f64,
203    role: Role,
204}
205
206/// Which point-element kind is being expanded, borrowing the base text for `text`.
207enum PointRole<'a> {
208    Dot,
209    Tick,
210    Text(&'a str),
211}
212
213impl PointRole<'_> {
214    /// The [`Role`] for one instance. `ladder_label` is `Some` only for an auto-numbered
215    /// text ladder copy; otherwise a text keeps its element's own string.
216    fn role_with_label(&self, ladder_label: Option<String>) -> Role {
217        match self {
218            PointRole::Dot => Role::Dot,
219            PointRole::Tick => Role::Tick,
220            PointRole::Text(base) => Role::Text(ladder_label.unwrap_or_else(|| (*base).to_string())),
221        }
222    }
223
224    /// The auto-numbered label for copy `i` of a text ladder, or `None` when this is not a
225    /// labeled text ladder (dot/tick, or text without `label:true`).
226    fn ladder_label(&self, repeat: &Repeat, i: u32) -> Option<String> {
227        match self {
228            PointRole::Text(_) if repeat.label => {
229                Some(format_label_number(repeat.label_start + f64::from(i) * repeat.label_step))
230            }
231            _ => None,
232        }
233    }
234}
235
236/// Expand every element's `repeat` into concrete instances, applying `scale` to positions
237/// and enforcing the mark cap along the way. Decoration and unknown element types are
238/// dropped (not emitted, not counted).
239fn expand_elements(elements: &[Element], scale: f64) -> Result<Vec<ExpandedInstance>, ReticleError> {
240    let mut out: Vec<ExpandedInstance> = Vec::new();
241    for element in elements {
242        let (x, y, repeat, base) = match element {
243            Element::Dot { x, y, repeat } => (*x, *y, repeat.as_ref(), PointRole::Dot),
244            Element::Tick { x, y, repeat } => (*x, *y, repeat.as_ref(), PointRole::Tick),
245            Element::Text {
246                x, y, text, repeat,
247            } => (*x, *y, repeat.as_ref(), PointRole::Text(text)),
248            // Decoration and unknown future element types carry no hold and no label.
249            Element::Line {}
250            | Element::Circle {}
251            | Element::Rect {}
252            | Element::Grid {}
253            | Element::Unknown => continue,
254        };
255        expand_point(x, y, repeat, scale, base, &mut out)?;
256    }
257    Ok(out)
258}
259
260/// Expand a single point element (`dot`/`tick`/`text`) — with or without a `repeat` — into
261/// `out`, applying `scale` and enforcing the cap.
262fn expand_point(
263    x: f64,
264    y: f64,
265    repeat: Option<&Repeat>,
266    scale: f64,
267    base: PointRole<'_>,
268    out: &mut Vec<ExpandedInstance>,
269) -> Result<(), ReticleError> {
270    // Ventum uses +x = right, +y = down — the same axes the engine's marks use — so a point
271    // at (x, y) maps to (down = y, right = x), unit-scaled.
272    let Some(repeat) = repeat else {
273        // No repeat: a single instance at (x, y).
274        let role = base.role_with_label(None);
275        return push_capped(y * scale, x * scale, role, out);
276    };
277
278    for i in 0..repeat.n {
279        let offset = f64::from(i) * repeat.step;
280        // Step the chosen axis; the other axis is unchanged.
281        let (cx, cy) = match repeat.axis {
282            Axis::X => (x + offset, y),
283            Axis::Y => (x, y + offset),
284        };
285        // The coordinate on the stepped axis decides whether a mirrored copy would land on
286        // center (and so must be skipped, to avoid a duplicate).
287        let stepped = match repeat.axis {
288            Axis::X => cx,
289            Axis::Y => cy,
290        };
291
292        let down = cy * scale;
293        let right = cx * scale;
294        let role = base.role_with_label(base.ladder_label(repeat, i));
295
296        if repeat.mirror && stepped != 0.0 {
297            // Emit the axis-negated twin as well, but never a duplicate at the center. Only
298            // the stepped axis is negated. A mirrored labeled-text copy keeps the SAME label
299            // as its positive twin (magnitude convention: the "5" on the right and the "5"
300            // on the left both read 5). The written schema does not specify this, so it began
301            // as a deliberate assumption; the format's author confirmed it on 2026-08-01
302            // ("mirrored numbers are without signs"). Do not "fix" this into signed labels.
303            let (mirror_down, mirror_right) = match repeat.axis {
304                Axis::X => (down, -right),
305                Axis::Y => (-down, right),
306            };
307            push_capped(down, right, role.clone(), out)?;
308            push_capped(mirror_down, mirror_right, role, out)?;
309        } else {
310            push_capped(down, right, role, out)?;
311        }
312    }
313    Ok(())
314}
315
316/// Push one instance, rejecting the reticle the instant the running count would exceed
317/// [`MAX_RETICLE_MARKS`]. Because this checks before every push, expansion allocates at
318/// most `MAX_RETICLE_MARKS + 1` instances regardless of how large a `repeat.n` is, so a
319/// runaway ladder returns promptly instead of hanging or exhausting memory. Text instances
320/// count toward the same cap as marks, so no element kind can grow unbounded.
321fn push_capped(
322    down: f64,
323    right: f64,
324    role: Role,
325    out: &mut Vec<ExpandedInstance>,
326) -> Result<(), ReticleError> {
327    if out.len() >= MAX_RETICLE_MARKS {
328        return Err(ReticleError::TooManyMarks {
329            count: out.len() + 1,
330            max: MAX_RETICLE_MARKS,
331        });
332    }
333    out.push(ExpandedInstance { down, right, role });
334    Ok(())
335}
336
337/// Format a ladder label value so integers print without a fractional part (`5.0 -> "5"`)
338/// while fractional values keep their shortest decimal form (`5.5 -> "5.5"`).
339fn format_label_number(value: f64) -> String {
340    if value.is_finite() && value == value.round() {
341        (value as i64).to_string()
342    } else {
343        value.to_string()
344    }
345}
346
347// ---------------------------------------------------------------------------------------
348// Ventum input model (serde). Deliberately permissive: no `deny_unknown_fields`, so
349// cosmetic keys (color, width, cx/cy, size, len, orient, r, max_extent, tube_diameter,
350// notes, manufacturer, ...) are ignored rather than rejected.
351// ---------------------------------------------------------------------------------------
352
353/// A whole Ventum reticle: metadata plus its drawing `spec`.
354#[derive(Debug, Deserialize)]
355struct VentumReticle {
356    #[serde(default)]
357    name: String,
358    /// `"ffp"` / `"sfp"` — deserialized directly into the engine's [`FocalPlane`].
359    #[serde(default)]
360    plane: FocalPlane,
361    /// The magnification the subtensions are true at (SFP only).
362    #[serde(default = "default_ref_magnification")]
363    ref_magnification: f64,
364    /// The angular unit for every coordinate in `spec`.
365    #[serde(default)]
366    unit: Unit,
367    #[serde(default)]
368    spec: Spec,
369}
370
371fn default_ref_magnification() -> f64 {
372    1.0
373}
374
375/// The angular unit of a Ventum reticle's coordinates.
376#[derive(Debug, Clone, Copy, Default, Deserialize)]
377#[serde(rename_all = "snake_case")]
378enum Unit {
379    #[default]
380    Mil,
381    Moa,
382}
383
384/// The `spec` field: a JSON array of [`Element`]s, given EITHER as a JSON array OR as a
385/// string containing that array (the schema shows both forms). The custom deserializer
386/// accepts either and parses the string form with `serde_json`.
387#[derive(Debug, Default)]
388struct Spec(Vec<Element>);
389
390impl<'de> Deserialize<'de> for Spec {
391    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
392    where
393        D: Deserializer<'de>,
394    {
395        struct SpecVisitor;
396
397        impl<'de> Visitor<'de> for SpecVisitor {
398            type Value = Spec;
399
400            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
401                f.write_str("a JSON array of reticle elements, or a string containing that array")
402            }
403
404            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
405            where
406                E: de::Error,
407            {
408                let elements: Vec<Element> =
409                    serde_json::from_str(v).map_err(de::Error::custom)?;
410                Ok(Spec(elements))
411            }
412
413            fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
414            where
415                A: SeqAccess<'de>,
416            {
417                let elements =
418                    Vec::<Element>::deserialize(de::value::SeqAccessDeserializer::new(seq))?;
419                Ok(Spec(elements))
420            }
421        }
422
423        deserializer.deserialize_any(SpecVisitor)
424    }
425}
426
427/// One drawing element, internally tagged on `"type"`. Unknown cosmetic fields on each
428/// variant are ignored; unknown element *types* map to [`Element::Unknown`].
429#[derive(Debug, Deserialize)]
430#[serde(tag = "type", rename_all = "snake_case")]
431enum Element {
432    /// A filled dot (hold-bearing). `r` and any other cosmetic fields are ignored.
433    Dot {
434        x: f64,
435        y: f64,
436        #[serde(default)]
437        repeat: Option<Repeat>,
438    },
439    /// A short hash mark (hold-bearing); its hold point is its `(x, y)` anchor. `len` and
440    /// `orient` are ignored.
441    Tick {
442        x: f64,
443        y: f64,
444        #[serde(default)]
445        repeat: Option<Repeat>,
446    },
447    /// A standalone text label positioned in reticle coordinates. `size` is ignored.
448    Text {
449        x: f64,
450        y: f64,
451        text: String,
452        #[serde(default)]
453        repeat: Option<Repeat>,
454    },
455    /// Decoration — dropped. Its geometry fields are ignored (empty struct variant so
456    /// serde skips every field).
457    Line {},
458    /// Decoration — dropped.
459    Circle {},
460    /// Decoration — dropped.
461    Rect {},
462    /// Decoration — dropped.
463    Grid {},
464    /// Any unknown future element type — dropped.
465    #[serde(other)]
466    Unknown,
467}
468
469/// The `repeat` operator: stamp `n` copies of an element along one axis, optionally
470/// mirrored, optionally auto-numbering a text ladder's labels.
471#[derive(Debug, Deserialize)]
472struct Repeat {
473    /// Which axis to step along.
474    axis: Axis,
475    /// Spacing between copies, in the reticle's unit.
476    step: f64,
477    /// Number of copies.
478    n: u32,
479    /// When true, also emit the axis-negated copy (a `±` symmetric ladder), except at the
480    /// center.
481    #[serde(default)]
482    mirror: bool,
483    /// (text only) When true, rewrite each copy's label to `label_start + i*label_step`.
484    #[serde(default)]
485    label: bool,
486    /// (text ladder) The value of the first copy's auto-number.
487    #[serde(rename = "labelStart", default)]
488    label_start: f64,
489    /// (text ladder) The auto-number increment per copy.
490    #[serde(rename = "labelStep", default = "default_label_step")]
491    label_step: f64,
492}
493
494fn default_label_step() -> f64 {
495    1.0
496}
497
498/// The axis a [`Repeat`] steps along.
499#[derive(Debug, Clone, Copy, Deserialize)]
500#[serde(rename_all = "snake_case")]
501enum Axis {
502    X,
503    Y,
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use crate::reticle::hold_point_in_reticle;
510
511    /// Bero's exact MBR dot-tree spec (the array form), as three `dot` rows each stamped by
512    /// a horizontal mirrored `repeat`.
513    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}}]"#;
514
515    /// Collect the `right_mil` values of every mark on a given `down_mil` row, sorted.
516    fn row_rights(desc: &ReticleDescription, down: f64) -> Vec<f64> {
517        let mut rights: Vec<f64> = desc
518            .marks
519            .iter()
520            .filter(|m| m.down_mil == down)
521            .map(|m| m.right_mil)
522            .collect();
523        rights.sort_by(|a, b| a.partial_cmp(b).unwrap());
524        rights
525    }
526
527    /// The set `{±1, ±2, ..., ±max}`, sorted ascending — the expected spread of one MBR row.
528    fn symmetric_spread(max: i32) -> Vec<f64> {
529        let mut v: Vec<f64> = Vec::new();
530        for k in 1..=max {
531            v.push(-f64::from(k));
532        }
533        for k in 1..=max {
534            v.push(f64::from(k));
535        }
536        v.sort_by(|a, b| a.partial_cmp(b).unwrap());
537        v
538    }
539
540    fn assert_mbr(desc: &ReticleDescription) {
541        // 78 marks: row y=4 spans ±1..±9 (18), y=8 spans ±1..±13 (26), y=12 spans ±1..±17 (34).
542        assert_eq!(desc.marks.len(), 78, "MBR expands to 78 holdable marks");
543        assert_eq!(row_rights(desc, 4.0), symmetric_spread(9), "row down=4 -> ±1..±9");
544        assert_eq!(row_rights(desc, 8.0), symmetric_spread(13), "row down=8 -> ±1..±13");
545        assert_eq!(row_rights(desc, 12.0), symmetric_spread(17), "row down=12 -> ±1..±17");
546        assert_eq!(row_rights(desc, 4.0).len(), 18);
547        assert_eq!(row_rights(desc, 8.0).len(), 26);
548        assert_eq!(row_rights(desc, 12.0).len(), 34);
549        // Every mark is a dot, none sits on the vertical axis, and none exceeds its row span.
550        assert!(desc.marks.iter().all(|m| m.kind == MarkKind::Dot), "all MBR marks are dots");
551        assert!(
552            desc.marks.iter().all(|m| m.right_mil != 0.0),
553            "no MBR mark lands on the center line"
554        );
555        assert!(
556            row_rights(desc, 4.0).iter().all(|&r| r.abs() <= 9.0),
557            "row down=4 has nothing beyond ±9"
558        );
559    }
560
561    #[test]
562    fn mbr_array_form_expands_to_the_full_dot_tree() {
563        let json = format!(r#"{{"name":"MBR","plane":"ffp","unit":"mil","spec":{MBR_SPEC}}}"#);
564        let desc = import_ventum_reticle(&json).unwrap();
565        assert_eq!(desc.name, "MBR");
566        assert_eq!(desc.focal_plane, FocalPlane::First);
567        assert_mbr(&desc);
568    }
569
570    #[test]
571    fn mbr_string_form_parses_identically_to_the_array_form() {
572        // `spec` given as a STRING containing the JSON array.
573        let string_form = serde_json::json!({
574            "name": "MBR", "plane": "ffp", "unit": "mil", "spec": MBR_SPEC
575        })
576        .to_string();
577        let array_form = format!(r#"{{"name":"MBR","plane":"ffp","unit":"mil","spec":{MBR_SPEC}}}"#);
578
579        let from_string = import_ventum_reticle(&string_form).unwrap();
580        let from_array = import_ventum_reticle(&array_form).unwrap();
581
582        assert_mbr(&from_string);
583        assert_eq!(
584            from_string, from_array,
585            "the string-wrapped spec must import identically to the array spec"
586        );
587    }
588
589    #[test]
590    fn sfp_carries_reference_magnification_but_ffp_normalizes_it() {
591        let sfp = import_ventum_reticle(
592            r#"{"name":"SE","plane":"sfp","ref_magnification":6,"unit":"mil",
593                "spec":[{"type":"dot","x":0,"y":1}]}"#,
594        )
595        .unwrap();
596        assert_eq!(sfp.focal_plane, FocalPlane::Second);
597        assert_eq!(sfp.reference_magnification, 6.0);
598
599        // An FFP reticle ignores any ref_magnification and normalizes to 1.0.
600        let ffp = import_ventum_reticle(
601            r#"{"name":"F","plane":"ffp","ref_magnification":6,"unit":"mil",
602                "spec":[{"type":"dot","x":0,"y":1}]}"#,
603        )
604        .unwrap();
605        assert_eq!(ffp.focal_plane, FocalPlane::First);
606        assert_eq!(ffp.reference_magnification, 1.0);
607    }
608
609    #[test]
610    fn moa_coordinates_convert_to_milliradians() {
611        let desc = import_ventum_reticle(
612            r#"{"name":"M","unit":"moa","spec":[{"type":"dot","x":0,"y":2}]}"#,
613        )
614        .unwrap();
615        assert_eq!(desc.marks.len(), 1);
616        assert!(
617            (desc.marks[0].down_mil - 2.0 * MOA_TO_MIL).abs() < 1e-6,
618            "2 MOA -> {} mil (~0.581776)",
619            desc.marks[0].down_mil
620        );
621    }
622
623    #[test]
624    fn text_binds_to_the_nearest_mark_and_far_text_is_dropped() {
625        let desc = import_ventum_reticle(
626            r#"{"name":"T","unit":"mil","spec":[
627                {"type":"tick","x":0,"y":5},
628                {"type":"text","x":0.5,"y":5,"text":"5"},
629                {"type":"text","x":10,"y":10,"text":"far"}
630            ]}"#,
631        )
632        .unwrap();
633        // One tick -> one mark; the nearby "5" binds, the far "far" is dropped.
634        assert_eq!(desc.marks.len(), 1);
635        assert_eq!(desc.marks[0].down_mil, 5.0);
636        assert_eq!(desc.marks[0].kind, MarkKind::Hash);
637        assert_eq!(desc.marks[0].label.as_deref(), Some("5"));
638        assert!(
639            desc.marks.iter().all(|m| m.label.as_deref() != Some("far")),
640            "distant text must not bind"
641        );
642    }
643
644    #[test]
645    fn mirror_never_duplicates_a_center_mark() {
646        // A mirrored ladder whose stepped coordinate is 0 emits a single mark (no twin).
647        let center = import_ventum_reticle(
648            r#"{"name":"C","unit":"mil","spec":[
649                {"type":"tick","x":0,"y":1,"repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
650            ]}"#,
651        )
652        .unwrap();
653        assert_eq!(center.marks.len(), 1, "no duplicate at the center");
654        assert_eq!(center.marks[0].right_mil, 0.0);
655
656        // Off center, the mirror emits both ± copies.
657        let off = import_ventum_reticle(
658            r#"{"name":"O","unit":"mil","spec":[
659                {"type":"tick","x":1,"y":1,"repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
660            ]}"#,
661        )
662        .unwrap();
663        assert_eq!(off.marks.len(), 2, "mirror of an off-center mark yields ±1");
664        let mut rights: Vec<f64> = off.marks.iter().map(|m| m.right_mil).collect();
665        rights.sort_by(|a, b| a.partial_cmp(b).unwrap());
666        assert_eq!(rights, vec![-1.0, 1.0]);
667    }
668
669    #[test]
670    fn text_ladder_auto_numbers_each_copy() {
671        // Factored expander: a labeled text ladder renumbers each copy from labelStart by
672        // labelStep (defaults 0 and 1).
673        let elements = vec![Element::Text {
674            x: 0.0,
675            y: 1.0,
676            text: "base".to_string(),
677            repeat: Some(Repeat {
678                axis: Axis::Y,
679                step: 1.0,
680                n: 3,
681                mirror: false,
682                label: true,
683                label_start: 0.0,
684                label_step: 1.0,
685            }),
686        }];
687        let instances = expand_elements(&elements, 1.0).unwrap();
688        assert_eq!(
689            instances,
690            vec![
691                ExpandedInstance { down: 1.0, right: 0.0, role: Role::Text("0".to_string()) },
692                ExpandedInstance { down: 2.0, right: 0.0, role: Role::Text("1".to_string()) },
693                ExpandedInstance { down: 3.0, right: 0.0, role: Role::Text("2".to_string()) },
694            ]
695        );
696    }
697
698    #[test]
699    fn label_numbers_print_integers_without_a_decimal() {
700        assert_eq!(format_label_number(5.0), "5");
701        assert_eq!(format_label_number(0.0), "0");
702        assert_eq!(format_label_number(-3.0), "-3");
703        assert_eq!(format_label_number(5.5), "5.5");
704    }
705
706    #[test]
707    fn a_runaway_repeat_is_capped_promptly() {
708        let json = r#"{"name":"X","unit":"mil","spec":[
709            {"type":"dot","x":0,"y":1,"repeat":{"axis":"x","step":1,"n":100000}}
710        ]}"#;
711        // Must reject with TooManyMarks without materializing 100000 marks (the cap is
712        // enforced during expansion, so this returns immediately).
713        assert!(matches!(
714            import_ventum_reticle(json),
715            Err(ReticleError::TooManyMarks { .. })
716        ));
717    }
718
719    #[test]
720    fn malformed_json_reports_invalid_spec() {
721        assert!(matches!(
722            import_ventum_reticle("{not json"),
723            Err(ReticleError::InvalidSpec(_))
724        ));
725        // A `spec` string that is not itself a JSON array is also an invalid spec.
726        assert!(matches!(
727            import_ventum_reticle(r#"{"name":"X","spec":"not-an-array"}"#),
728            Err(ReticleError::InvalidSpec(_))
729        ));
730    }
731
732    #[test]
733    fn decoration_and_unknown_elements_are_dropped() {
734        let desc = import_ventum_reticle(
735            r#"{"name":"D","unit":"mil","spec":[
736                {"type":"line","x1":-5,"y1":0,"x2":5,"y2":0,"width":0.05},
737                {"type":"circle","x":0,"y":0,"r":1.0},
738                {"type":"rect","x":-1,"y":-1,"w":2,"h":2},
739                {"type":"grid","x0":-5,"y0":-5,"x1":5,"y1":5,"step":1},
740                {"type":"future_shape","x":0,"y":0},
741                {"type":"dot","x":0,"y":2,"color":"red","width":0.1}
742            ]}"#,
743        )
744        .unwrap();
745        // Only the dot survives; every decoration and the unknown type are dropped.
746        assert_eq!(desc.marks.len(), 1);
747        assert_eq!(desc.marks[0].down_mil, 2.0);
748        assert_eq!(desc.marks[0].kind, MarkKind::Dot);
749    }
750
751    #[test]
752    fn imported_mbr_reticle_hold_solves() {
753        let json = format!(r#"{{"name":"MBR","plane":"ffp","unit":"mil","spec":{MBR_SPEC}}}"#);
754        let desc = import_ventum_reticle(&json).unwrap();
755        // (down 4, right 2) is a real MBR mark, so a matching hold lands on it.
756        let hold = hold_point_in_reticle(4.0, 2.0, 1.0, &desc).unwrap();
757        assert!(hold.nearest_mark.is_some());
758        assert!(
759            hold.nearest_mark_distance_mil < 0.5,
760            "hold should sit on/near a real mark (distance {})",
761            hold.nearest_mark_distance_mil
762        );
763        assert!(!hold.off_reticle, "a mid-tree hold is on the reticle");
764    }
765}