Skip to main content

ballistics_engine/
reticle.rs

1//! MBA-1361: reticle schema, parametric generators, and the hold-point-in-reticle API.
2//!
3//! Shooters who HOLD rather than dial need the answer expressed where they actually read
4//! it: a point in their own reticle. This module is the engine slice of that — one shared
5//! model (`serde`-serializable) that the CLI, the browser terminal, the FFI consumers and
6//! the front ends can all speak, plus the coordinate transform from an angular firing
7//! solution to a reticle coordinate. It is a transform plus a schema, not new physics: the
8//! raw material (angular drop and drift in milliradians) is already a first-class output
9//! everywhere in this crate.
10//!
11//! # Intellectual-property exclusions (deliberate, do not "fill in")
12//!
13//! Horus grid reticles and Time-of-Flight Wind Dots are actively patented, and Horus
14//! monetizes app integration through its own licensed app. Therefore this module has, and
15//! must keep having:
16//!
17//! * **no TREMOR-family / Horus grid layouts** — [`ReticleDescription::mil_grid`] builds a
18//!   plain mil-hash CROSS (marks along the two stadia), never a filled two-dimensional
19//!   grid, and [`ReticleDescription::tree`] is a generic parametric widening tree with no
20//!   vendor geometry in it;
21//! * **no wind-dot calibration** — nothing here maps a time of flight, a wind speed or a
22//!   "wind hold number" onto a dot. Wind enters only as an angular deflection the caller
23//!   already solved, in milliradians, exactly like elevation;
24//! * **no vendor reticle catalog.** Manufacturer subtension sheets are published facts and
25//!   are a legally viable catalog source, but curating one is a separate, per-vendor
26//!   IP-reviewed data project (a tracked follow-up), not this module.
27//!
28//! # Angular conventions (the whole set, in one place)
29//!
30//! Every angle here is a **milliradian (mil)**, and every reticle coordinate is measured
31//! **from the optical center**:
32//!
33//! * `down_mil` — POSITIVE is BELOW center. A holdover point is at positive `down_mil`.
34//! * `right_mil` — POSITIVE is to the shooter's RIGHT of center.
35//!
36//! The hold point follows straight from that. If the bullet falls `d` mil below the line
37//! of sight at some range, the shooter must place a reticle point `d` mil BELOW center on
38//! the target — so `down_mil == drop_mil`. If the wind pushes the bullet `w` mil to the
39//! RIGHT, the shooter must aim left by placing a point `w` mil to the RIGHT of center on
40//! the target — so `right_mil == wind_mil`. [`hold_point_in_reticle`] therefore carries
41//! the firing solution through unchanged and does the real work in the mark search; the
42//! value of stating it here is that every surface now agrees on which way is which.
43//!
44//! # Focal plane
45//!
46//! Published optics-manual math, no more:
47//!
48//! * **FFP** (first focal plane): the reticle is magnified with the image, so a mark
49//!   subtends the same angle at every magnification. Marks are used as authored.
50//! * **SFP** (second focal plane): the reticle is a fixed angular size at the eyepiece
51//!   while the target image scales, so a mark's TRUE subtension is
52//!   `nominal * reference_magnification / magnification`. A 2 mil mark on a reticle
53//!   calibrated at 10x covers 4 mil of target at 5x, and 1 mil at 20x.
54//!
55//! The hold point is a property of the trajectory, not of the optic, so it is always
56//! TRUE angular. The mark search therefore scales the MARKS into true angular space and
57//! compares there — never the other way round.
58
59use std::error::Error;
60use std::fmt;
61
62use serde::{Deserialize, Serialize};
63
64/// Largest mark count any generator will produce, and the most
65/// [`ReticleDescription::validate`] will accept.
66///
67/// A reticle is a human-readable aiming device; nothing legitimate approaches this. The
68/// cap exists so a hand-authored `--reticle-json` (or an FFI caller) cannot turn an
69/// `O(marks)` search into an unbounded one, in the same spirit as the FFI drag-table
70/// length guard (MBA-1407).
71pub const MAX_RETICLE_MARKS: usize = 4096;
72
73/// Fraction of each axis's mark span added as slack before a hold counts as
74/// [`ReticleHold::off_reticle`]. See that field for the exact rule.
75pub const OFF_RETICLE_MARGIN_FRACTION: f64 = 0.20;
76
77/// Which focal plane the reticle is etched in.
78///
79/// Serialized as `"ffp"` / `"sfp"` — the spellings every optics catalog and every shooter
80/// uses, rather than the Rust variant names.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
82pub enum FocalPlane {
83    /// First focal plane: subtensions are constant at every magnification.
84    #[serde(rename = "ffp")]
85    #[default]
86    First,
87    /// Second focal plane: subtensions scale as `reference_magnification / magnification`.
88    #[serde(rename = "sfp")]
89    Second,
90}
91
92impl FocalPlane {
93    /// `"FFP"` / `"SFP"`, for tables and help text.
94    pub fn label(self) -> &'static str {
95        match self {
96            FocalPlane::First => "FFP",
97            FocalPlane::Second => "SFP",
98        }
99    }
100
101    /// True when mark subtensions depend on magnification.
102    pub fn is_magnification_dependent(self) -> bool {
103        matches!(self, FocalPlane::Second)
104    }
105}
106
107/// What a mark looks like. Purely descriptive — the hold math treats every kind
108/// identically, and renderers use it to draw.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
110#[serde(rename_all = "snake_case")]
111pub enum MarkKind {
112    /// A round dot.
113    #[default]
114    Dot,
115    /// A short line across a stadium.
116    Hash,
117    /// A thick post (typically the lower stadium of a duplex).
118    Post,
119    /// The optical center / primary aiming point.
120    Center,
121}
122
123impl MarkKind {
124    /// Lower-case wire spelling, identical to the serde representation.
125    pub fn as_str(self) -> &'static str {
126        match self {
127            MarkKind::Dot => "dot",
128            MarkKind::Hash => "hash",
129            MarkKind::Post => "post",
130            MarkKind::Center => "center",
131        }
132    }
133}
134
135/// One aiming mark, positioned in NOMINAL angular units from the optical center.
136///
137/// "Nominal" means: as authored, i.e. the true subtension for an FFP reticle at any
138/// magnification, and for an SFP reticle at its
139/// [`ReticleDescription::reference_magnification`].
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141pub struct ReticleMark {
142    /// Milliradians BELOW the optical center (negative = above).
143    pub down_mil: f64,
144    /// Milliradians RIGHT of the optical center (negative = left).
145    pub right_mil: f64,
146    /// How the mark is drawn. Does not affect the hold math.
147    #[serde(default)]
148    pub kind: MarkKind,
149    /// Optional human label, e.g. a BDC mark's range (`"400 yd"`).
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub label: Option<String>,
152}
153
154impl ReticleMark {
155    /// A mark at `(down_mil, right_mil)` with no label.
156    pub fn new(down_mil: f64, right_mil: f64, kind: MarkKind) -> Self {
157        Self {
158            down_mil,
159            right_mil,
160            kind,
161            label: None,
162        }
163    }
164
165    /// A mark at `(down_mil, right_mil)` carrying `label`.
166    pub fn labeled(down_mil: f64, right_mil: f64, kind: MarkKind, label: impl Into<String>) -> Self {
167        Self {
168            down_mil,
169            right_mil,
170            kind,
171            label: Some(label.into()),
172        }
173    }
174}
175
176/// A complete reticle: its focal plane, its calibration magnification, and its marks.
177///
178/// This is THE shared schema. It is deliberately permissive on unknown JSON keys (no
179/// `deny_unknown_fields`) so a richer front-end description round-trips through the engine
180/// without being rejected, and every field a solve needs is required.
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
182pub struct ReticleDescription {
183    /// Display name, e.g. `"mil-grid 0.5/10"` or a vendor model designation.
184    pub name: String,
185    pub focal_plane: FocalPlane,
186    /// The magnification at which [`ReticleMark`] subtensions are true. Meaningful only
187    /// for [`FocalPlane::Second`]; ignored (and unvalidated) for FFP, where subtensions
188    /// are magnification-independent by construction.
189    pub reference_magnification: f64,
190    pub marks: Vec<ReticleMark>,
191}
192
193/// Why a reticle operation was rejected. Typed rather than stringly, so front ends can
194/// render their own wording and the FFI can map to a code.
195#[derive(Debug, Clone, PartialEq)]
196pub enum ReticleError {
197    /// `magnification` was not finite and strictly positive. Checked on EVERY focal plane
198    /// — an FFP hold does not depend on it, but zero magnification is not a physical
199    /// optic and silently accepting it would mask a caller bug.
200    NonPositiveMagnification { magnification: f64 },
201    /// An SFP reticle carried a non-finite or non-positive
202    /// [`ReticleDescription::reference_magnification`], which its subtension scaling
203    /// divides by conceptually and multiplies by literally.
204    NonPositiveReferenceMagnification { reference_magnification: f64 },
205    /// The description carried no marks. A hold point has nothing to be near.
206    NoMarks,
207    /// The description carried more than [`MAX_RETICLE_MARKS`] marks.
208    TooManyMarks { count: usize, max: usize },
209    /// A mark's coordinates were not finite.
210    NonFiniteMark { index: usize },
211    /// The supplied firing solution (`drop_mil` / `wind_mil`) was not finite.
212    NonFiniteHold { drop_mil: f64, wind_mil: f64 },
213    /// A generator parameter violated its rule.
214    InvalidGeneratorParameter {
215        parameter: &'static str,
216        value: f64,
217        rule: &'static str,
218    },
219}
220
221impl fmt::Display for ReticleError {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        match self {
224            ReticleError::NonPositiveMagnification { magnification } => write!(
225                f,
226                "magnification must be finite and greater than zero (got {magnification})"
227            ),
228            ReticleError::NonPositiveReferenceMagnification {
229                reference_magnification,
230            } => write!(
231                f,
232                "an SFP reticle's reference magnification must be finite and greater than \
233                 zero (got {reference_magnification})"
234            ),
235            ReticleError::NoMarks => {
236                write!(f, "the reticle description carries no marks")
237            }
238            ReticleError::TooManyMarks { count, max } => write!(
239                f,
240                "the reticle description carries {count} marks, more than the supported maximum of {max}"
241            ),
242            ReticleError::NonFiniteMark { index } => write!(
243                f,
244                "reticle mark {index} has non-finite coordinates"
245            ),
246            ReticleError::NonFiniteHold { drop_mil, wind_mil } => write!(
247                f,
248                "the hold must be finite (got drop {drop_mil} mil, wind {wind_mil} mil)"
249            ),
250            ReticleError::InvalidGeneratorParameter {
251                parameter,
252                value,
253                rule,
254            } => write!(f, "{parameter} must be {rule} (got {value})"),
255        }
256    }
257}
258
259impl Error for ReticleError {}
260
261/// Where a firing solution lands in a reticle (MBA-1361).
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263pub struct ReticleHold {
264    /// TRUE angular milliradians BELOW center — equal to the supplied angular drop. See
265    /// the module's conventions section for why this is an identity and not a transform.
266    pub down_mil: f64,
267    /// TRUE angular milliradians RIGHT of center — equal to the supplied angular wind
268    /// deflection.
269    pub right_mil: f64,
270    /// Index into [`ReticleDescription::marks`] of the mark nearest the hold, in TRUE
271    /// angular space (i.e. after SFP scaling). `None` only when the reticle has no marks,
272    /// which [`hold_point_in_reticle`] rejects — so in practice this is always `Some`.
273    pub nearest_mark: Option<usize>,
274    /// Euclidean distance (mil) from the hold to that mark, measured in TRUE angular
275    /// space. `0.0` when the hold lands exactly on a mark.
276    pub nearest_mark_distance_mil: f64,
277    /// True when the hold falls outside the marks' bounding box grown by
278    /// [`OFF_RETICLE_MARGIN_FRACTION`] of that box's span, PER AXIS.
279    ///
280    /// Precisely: let `[lo, hi]` be the min/max of the TRUE-angular mark coordinates on an
281    /// axis and `m = OFF_RETICLE_MARGIN_FRACTION * (hi - lo)`; the hold is off-reticle
282    /// when it lies outside `[lo - m, hi + m]` on either axis. A degenerate axis (all
283    /// marks share a coordinate, e.g. a pure BDC ladder with no windage marks) has
284    /// `m == 0`, so ANY deviation on that axis reads as off-reticle. That is deliberate:
285    /// such a reticle genuinely offers nothing to hold on in that direction.
286    pub off_reticle: bool,
287    /// The SFP subtension scale actually applied to the marks
288    /// (`reference_magnification / magnification`); exactly `1.0` for FFP.
289    pub mark_scale: f64,
290}
291
292/// The true-angular position of a mark after focal-plane scaling.
293#[derive(Debug, Clone, Copy, PartialEq)]
294pub struct ScaledMark {
295    pub down_mil: f64,
296    pub right_mil: f64,
297}
298
299impl ReticleDescription {
300    /// Validate the description on its own terms: mark count, finiteness, and (SFP only)
301    /// the reference magnification.
302    pub fn validate(&self) -> Result<(), ReticleError> {
303        if self.marks.is_empty() {
304            return Err(ReticleError::NoMarks);
305        }
306        if self.marks.len() > MAX_RETICLE_MARKS {
307            return Err(ReticleError::TooManyMarks {
308                count: self.marks.len(),
309                max: MAX_RETICLE_MARKS,
310            });
311        }
312        for (index, mark) in self.marks.iter().enumerate() {
313            if !mark.down_mil.is_finite() || !mark.right_mil.is_finite() {
314                return Err(ReticleError::NonFiniteMark { index });
315            }
316        }
317        if self.focal_plane.is_magnification_dependent()
318            && (!self.reference_magnification.is_finite() || self.reference_magnification <= 0.0)
319        {
320            return Err(ReticleError::NonPositiveReferenceMagnification {
321                reference_magnification: self.reference_magnification,
322            });
323        }
324        Ok(())
325    }
326
327    /// The factor that converts NOMINAL mark subtensions to TRUE subtensions at
328    /// `magnification`: `reference_magnification / magnification` for SFP, exactly `1.0`
329    /// for FFP.
330    ///
331    /// Assumes [`Self::validate`] has passed and `magnification` is finite and positive.
332    pub fn mark_scale(&self, magnification: f64) -> f64 {
333        match self.focal_plane {
334            FocalPlane::First => 1.0,
335            FocalPlane::Second => self.reference_magnification / magnification,
336        }
337    }
338
339    /// Every mark's TRUE angular position at `magnification`.
340    pub fn scaled_marks(&self, magnification: f64) -> Result<Vec<ScaledMark>, ReticleError> {
341        self.validate()?;
342        require_positive_magnification(magnification)?;
343        let scale = self.mark_scale(magnification);
344        Ok(self
345            .marks
346            .iter()
347            .map(|mark| ScaledMark {
348                down_mil: mark.down_mil * scale,
349                right_mil: mark.right_mil * scale,
350            })
351            .collect())
352    }
353
354    /// A plain mil-hash CROSS: marks every `spacing_mil` along the vertical and horizontal
355    /// stadia out to `extent_mil`, plus a [`MarkKind::Center`] at the origin.
356    ///
357    /// This is NOT a filled two-dimensional grid — see the module header's IP exclusions.
358    /// The result is FFP with a `reference_magnification` of 1.0 (unused for FFP);
359    /// callers wanting an SFP grid set those two fields afterwards.
360    pub fn mil_grid(spacing_mil: f64, extent_mil: f64) -> Result<Self, ReticleError> {
361        require_generator_positive("spacing", spacing_mil)?;
362        require_generator_positive("extent", extent_mil)?;
363        if extent_mil < spacing_mil {
364            return Err(ReticleError::InvalidGeneratorParameter {
365                parameter: "extent",
366                value: extent_mil,
367                rule: "greater than or equal to the spacing",
368            });
369        }
370        let steps = (extent_mil / spacing_mil).floor() as usize;
371        // 1 center + 4 hashes per step; checked so a huge extent cannot wrap the count
372        // below the cap and unleash the loop below.
373        require_generated_size_checked(4usize.checked_mul(steps).and_then(|v| v.checked_add(1)))?;
374
375        let mut marks = Vec::with_capacity(1 + 4 * steps);
376        marks.push(ReticleMark::new(0.0, 0.0, MarkKind::Center));
377        for step in 1..=steps {
378            let offset = spacing_mil * step as f64;
379            // Vertical stadium: below then above center.
380            marks.push(ReticleMark::new(offset, 0.0, MarkKind::Hash));
381            marks.push(ReticleMark::new(-offset, 0.0, MarkKind::Hash));
382            // Horizontal stadium: right then left of center.
383            marks.push(ReticleMark::new(0.0, offset, MarkKind::Hash));
384            marks.push(ReticleMark::new(0.0, -offset, MarkKind::Hash));
385        }
386        Ok(Self {
387            name: format!("mil-cross {spacing_mil}/{extent_mil}"),
388            focal_plane: FocalPlane::First,
389            reference_magnification: 1.0,
390            marks,
391        })
392    }
393
394    /// A generic parametric holdover tree: `rows` rows below center at `row_spacing_mil`
395    /// intervals, each row `n` carrying windage dots at `±k * spread_step_mil` for
396    /// `k` in `1..=n`, so the tree widens with depth. Plus a [`MarkKind::Center`].
397    ///
398    /// Generic geometry only — no vendor tree layout is reproduced here (module header).
399    pub fn tree(
400        rows: usize,
401        row_spacing_mil: f64,
402        spread_step_mil: f64,
403    ) -> Result<Self, ReticleError> {
404        if rows == 0 {
405            return Err(ReticleError::InvalidGeneratorParameter {
406                parameter: "rows",
407                value: 0.0,
408                rule: "at least 1",
409            });
410        }
411        require_generator_positive("row-spacing", row_spacing_mil)?;
412        require_generator_positive("spread-step", spread_step_mil)?;
413        // 1 center + per row: the on-axis mark plus 2 windage dots per step. Checked so a
414        // huge `rows` cannot wrap the product below the cap and unleash the loops below.
415        require_generated_size_checked(
416            rows.checked_add(1)
417                .and_then(|r1| rows.checked_mul(r1))
418                .and_then(|p| p.checked_add(rows))
419                .and_then(|p| p.checked_add(1)),
420        )?;
421
422        let mut marks = Vec::new();
423        marks.push(ReticleMark::new(0.0, 0.0, MarkKind::Center));
424        for row in 1..=rows {
425            let down = row_spacing_mil * row as f64;
426            marks.push(ReticleMark::new(down, 0.0, MarkKind::Hash));
427            for step in 1..=row {
428                let spread = spread_step_mil * step as f64;
429                marks.push(ReticleMark::new(down, spread, MarkKind::Dot));
430                marks.push(ReticleMark::new(down, -spread, MarkKind::Dot));
431            }
432        }
433        Ok(Self {
434            name: format!("tree {rows}x{row_spacing_mil}/{spread_step_mil}"),
435            focal_plane: FocalPlane::First,
436            reference_magnification: 1.0,
437            marks,
438        })
439    }
440
441    /// A BDC ladder built from ALREADY-SOLVED drops: one labeled hash per
442    /// `(range_m, drop_mil)` pair on the vertical stadium, plus a [`MarkKind::Center`].
443    ///
444    /// This generator deliberately does NOT run a solve. It is pure data assembly, so the
445    /// caller stays in control of which load, atmosphere and zero the ladder describes,
446    /// and this module keeps its "no physics" property.
447    pub fn bdc_from_drops(drops: &[(f64, f64)]) -> Result<Self, ReticleError> {
448        if drops.is_empty() {
449            return Err(ReticleError::InvalidGeneratorParameter {
450                parameter: "drops",
451                value: 0.0,
452                rule: "a non-empty list of (range, drop) pairs",
453            });
454        }
455        require_generated_size(1 + drops.len())?;
456        for &(range_m, drop_mil) in drops {
457            if !range_m.is_finite() || range_m <= 0.0 {
458                return Err(ReticleError::InvalidGeneratorParameter {
459                    parameter: "drop range",
460                    value: range_m,
461                    rule: "finite and greater than zero",
462                });
463            }
464            if !drop_mil.is_finite() {
465                return Err(ReticleError::InvalidGeneratorParameter {
466                    parameter: "drop",
467                    value: drop_mil,
468                    rule: "finite",
469                });
470            }
471        }
472
473        let mut marks = Vec::with_capacity(1 + drops.len());
474        marks.push(ReticleMark::new(0.0, 0.0, MarkKind::Center));
475        for &(range_m, drop_mil) in drops {
476            marks.push(ReticleMark::labeled(
477                drop_mil,
478                0.0,
479                MarkKind::Hash,
480                format!("{range_m} m"),
481            ));
482        }
483        Ok(Self {
484            name: format!("bdc {} marks", drops.len()),
485            focal_plane: FocalPlane::First,
486            reference_magnification: 1.0,
487            marks,
488        })
489    }
490}
491
492fn require_positive_magnification(magnification: f64) -> Result<(), ReticleError> {
493    if !magnification.is_finite() || magnification <= 0.0 {
494        return Err(ReticleError::NonPositiveMagnification { magnification });
495    }
496    Ok(())
497}
498
499fn require_generator_positive(parameter: &'static str, value: f64) -> Result<(), ReticleError> {
500    if !value.is_finite() || value <= 0.0 {
501        return Err(ReticleError::InvalidGeneratorParameter {
502            parameter,
503            value,
504            rule: "finite and greater than zero",
505        });
506    }
507    Ok(())
508}
509
510fn require_generated_size(count: usize) -> Result<(), ReticleError> {
511    if count > MAX_RETICLE_MARKS {
512        return Err(ReticleError::TooManyMarks {
513            count,
514            max: MAX_RETICLE_MARKS,
515        });
516    }
517    Ok(())
518}
519
520/// Same cap as [`require_generated_size`], but for a mark count assembled by `usize`
521/// arithmetic that could overflow (a huge `--extent`/`--rows`). A `None` — the caller's
522/// `checked_*` chain overflowed — is itself proof the count is far past the cap, so it is
523/// rejected. Without this, `1 + 4 * steps` (or the tree product) wraps to a small value
524/// and silently bypasses `MAX_RETICLE_MARKS`, then the generator loop runs unbounded.
525fn require_generated_size_checked(count: Option<usize>) -> Result<(), ReticleError> {
526    match count {
527        Some(c) => require_generated_size(c),
528        None => Err(ReticleError::TooManyMarks {
529            count: usize::MAX,
530            max: MAX_RETICLE_MARKS,
531        }),
532    }
533}
534
535/// Place a firing solution in a reticle (MBA-1361).
536///
537/// `drop_mil` is the angular drop below the line of sight (positive = below, i.e. the
538/// come-up the shooter would otherwise dial) and `wind_mil` the angular wind deflection
539/// (positive = the bullet goes RIGHT). `magnification` is the optic's CURRENT setting.
540///
541/// The returned hold coordinates are TRUE angular and equal to the inputs (see the module
542/// header). The work this function does is the mark search: it scales the reticle's marks
543/// into true angular space for the given magnification and focal plane, finds the nearest
544/// one, and reports whether the hold has run off the marked part of the reticle.
545///
546/// # Errors
547///
548/// [`ReticleError::NonPositiveMagnification`] for a non-physical magnification (on EVERY
549/// focal plane), [`ReticleError::NonPositiveReferenceMagnification`] for an SFP reticle
550/// with no usable calibration magnification, [`ReticleError::NonFiniteHold`] for a
551/// non-finite firing solution, and the [`ReticleDescription::validate`] errors for a
552/// malformed description.
553pub fn hold_point_in_reticle(
554    drop_mil: f64,
555    wind_mil: f64,
556    magnification: f64,
557    reticle: &ReticleDescription,
558) -> Result<ReticleHold, ReticleError> {
559    reticle.validate()?;
560    require_positive_magnification(magnification)?;
561    if !drop_mil.is_finite() || !wind_mil.is_finite() {
562        return Err(ReticleError::NonFiniteHold { drop_mil, wind_mil });
563    }
564
565    let scale = reticle.mark_scale(magnification);
566    let scaled: Vec<ScaledMark> = reticle
567        .marks
568        .iter()
569        .map(|mark| ScaledMark {
570            down_mil: mark.down_mil * scale,
571            right_mil: mark.right_mil * scale,
572        })
573        .collect();
574
575    // Nearest mark in TRUE angular space. Ties resolve to the LOWEST index (strict `<`),
576    // which makes the answer independent of how the search is ordered.
577    let mut nearest_index = 0usize;
578    let mut nearest_distance = f64::INFINITY;
579    for (index, mark) in scaled.iter().enumerate() {
580        let distance = ((drop_mil - mark.down_mil).powi(2) + (wind_mil - mark.right_mil).powi(2))
581            .sqrt();
582        if distance < nearest_distance {
583            nearest_distance = distance;
584            nearest_index = index;
585        }
586    }
587
588    let (down_lo, down_hi) = span(scaled.iter().map(|m| m.down_mil));
589    let (right_lo, right_hi) = span(scaled.iter().map(|m| m.right_mil));
590    let down_margin = OFF_RETICLE_MARGIN_FRACTION * (down_hi - down_lo);
591    let right_margin = OFF_RETICLE_MARGIN_FRACTION * (right_hi - right_lo);
592    let off_reticle = drop_mil < down_lo - down_margin
593        || drop_mil > down_hi + down_margin
594        || wind_mil < right_lo - right_margin
595        || wind_mil > right_hi + right_margin;
596
597    Ok(ReticleHold {
598        down_mil: drop_mil,
599        right_mil: wind_mil,
600        nearest_mark: Some(nearest_index),
601        nearest_mark_distance_mil: nearest_distance,
602        off_reticle,
603        mark_scale: scale,
604    })
605}
606
607/// Min/max of a non-empty finite iterator. Callers have already validated finiteness.
608fn span(values: impl Iterator<Item = f64>) -> (f64, f64) {
609    let mut lo = f64::INFINITY;
610    let mut hi = f64::NEG_INFINITY;
611    for value in values {
612        if value < lo {
613            lo = value;
614        }
615        if value > hi {
616            hi = value;
617        }
618    }
619    (lo, hi)
620}
621
622/// Rendering shape for the `reticle` command family.
623#[derive(Debug, Clone, Copy, PartialEq, Eq)]
624pub enum ReticleFormat {
625    Table,
626    Json,
627}
628
629/// Render a hold point, identically on every surface (MBA-1361).
630///
631/// This is THE formatter — the native CLI's `reticle hold` and the browser terminal's both
632/// call it, so their output cannot drift apart. Same lesson as
633/// [`crate::drag::format_reference_drag_curve`]: the `recoil` CSV header diverged between
634/// the two surfaces precisely because each carried its own copy of the format strings.
635///
636/// Returned strings are newline-terminated; callers print or splice them verbatim.
637pub fn format_reticle_hold(
638    hold: &ReticleHold,
639    reticle: &ReticleDescription,
640    magnification: f64,
641    format: ReticleFormat,
642) -> String {
643    let nearest = hold.nearest_mark.and_then(|index| reticle.marks.get(index));
644    match format {
645        ReticleFormat::Json => {
646            let mut value = serde_json::json!({
647                "reticle": reticle.name,
648                "focal_plane": reticle.focal_plane.label(),
649                "reference_magnification": reticle.reference_magnification,
650                "magnification": magnification,
651                "mark_scale": hold.mark_scale,
652                "hold": {
653                    "down_mil": hold.down_mil,
654                    "right_mil": hold.right_mil,
655                },
656                "off_reticle": hold.off_reticle,
657                "nearest_mark": serde_json::Value::Null,
658            });
659            if let (Some(index), Some(mark)) = (hold.nearest_mark, nearest) {
660                let scale = hold.mark_scale;
661                value["nearest_mark"] = serde_json::json!({
662                    "index": index,
663                    "kind": mark.kind.as_str(),
664                    "label": mark.label,
665                    "nominal_down_mil": mark.down_mil,
666                    "nominal_right_mil": mark.right_mil,
667                    "true_down_mil": mark.down_mil * scale,
668                    "true_right_mil": mark.right_mil * scale,
669                    "distance_mil": hold.nearest_mark_distance_mil,
670                });
671            }
672            format!(
673                "{}\n",
674                serde_json::to_string_pretty(&value)
675                    .unwrap_or_else(|_| "{\"error\":\"serialization failed\"}".to_string())
676            )
677        }
678        ReticleFormat::Table => {
679            let mut out = String::new();
680            out.push_str("Reticle Hold Point\n");
681            out.push_str("==================\n\n");
682            out.push_str(&format!("Reticle:          {}\n", reticle.name));
683            out.push_str(&format!(
684                "Focal plane:      {}\n",
685                reticle.focal_plane.label()
686            ));
687            if reticle.focal_plane.is_magnification_dependent() {
688                out.push_str(&format!(
689                    "Reference mag:    {:.2}x\n",
690                    reticle.reference_magnification
691                ));
692                out.push_str(&format!("Magnification:    {magnification:.2}x\n"));
693                out.push_str(&format!(
694                    "Subtension scale: {:.4}x (marks read {:.4}x their etched value)\n",
695                    hold.mark_scale, hold.mark_scale
696                ));
697            } else {
698                out.push_str(&format!(
699                    "Magnification:    {magnification:.2}x (FFP: subtensions are magnification-independent)\n"
700                ));
701            }
702            out.push('\n');
703            out.push_str(&format!("Hold down:  {:>8.3} mil\n", hold.down_mil));
704            out.push_str(&format!("Hold right: {:>8.3} mil\n", hold.right_mil));
705            out.push('\n');
706            match nearest {
707                Some(mark) => {
708                    let scale = hold.mark_scale;
709                    let label = mark.label.as_deref().unwrap_or("-");
710                    out.push_str(&format!(
711                        "Nearest mark:     #{} {} ({})\n",
712                        hold.nearest_mark.unwrap_or(0),
713                        mark.kind.as_str(),
714                        label
715                    ));
716                    out.push_str(&format!(
717                        "  at (down {:.3}, right {:.3}) mil true\n",
718                        mark.down_mil * scale,
719                        mark.right_mil * scale
720                    ));
721                    out.push_str(&format!(
722                        "  distance from hold: {:.3} mil\n",
723                        hold.nearest_mark_distance_mil
724                    ));
725                }
726                None => out.push_str("Nearest mark:     none\n"),
727            }
728            if hold.off_reticle {
729                out.push_str(
730                    "\nWARNING: the hold falls outside the marked area of this reticle \
731                     (dial instead, or use a reticle with more holdover).\n",
732                );
733            }
734            out
735        }
736    }
737}
738
739/// Render a reticle description, identically on every surface (MBA-1361).
740///
741/// `-o json` emits the schema verbatim, so the output of `reticle generate ... -o json` is
742/// exactly what `reticle hold --reticle-json` consumes.
743pub fn format_reticle_description(reticle: &ReticleDescription, format: ReticleFormat) -> String {
744    match format {
745        ReticleFormat::Json => format!(
746            "{}\n",
747            serde_json::to_string_pretty(reticle)
748                .unwrap_or_else(|_| "{\"error\":\"serialization failed\"}".to_string())
749        ),
750        ReticleFormat::Table => {
751            let mut out = String::new();
752            out.push_str(&format!("Reticle: {}\n", reticle.name));
753            out.push_str(&format!(
754                "Focal plane: {}",
755                reticle.focal_plane.label()
756            ));
757            if reticle.focal_plane.is_magnification_dependent() {
758                out.push_str(&format!(
759                    "  Reference magnification: {:.2}x",
760                    reticle.reference_magnification
761                ));
762            }
763            out.push_str(&format!("  Marks: {}\n\n", reticle.marks.len()));
764            out.push_str("   # Kind    Down(mil)  Right(mil)  Label\n");
765            out.push_str("---- ------- ---------- ----------- --------------------\n");
766            for (index, mark) in reticle.marks.iter().enumerate() {
767                out.push_str(&format!(
768                    "{:>4} {:<7} {:>10.3} {:>11.3}  {}\n",
769                    index,
770                    mark.kind.as_str(),
771                    mark.down_mil,
772                    mark.right_mil,
773                    mark.label.as_deref().unwrap_or("-")
774                ));
775            }
776            out
777        }
778    }
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784
785    fn sfp_two_mil_at_ten() -> ReticleDescription {
786        ReticleDescription {
787            name: "test sfp".to_string(),
788            focal_plane: FocalPlane::Second,
789            reference_magnification: 10.0,
790            marks: vec![
791                ReticleMark::new(0.0, 0.0, MarkKind::Center),
792                ReticleMark::new(2.0, 0.0, MarkKind::Hash),
793                ReticleMark::new(4.0, 0.0, MarkKind::Hash),
794            ],
795        }
796    }
797
798    fn ffp_ladder() -> ReticleDescription {
799        ReticleDescription {
800            name: "test ffp".to_string(),
801            focal_plane: FocalPlane::First,
802            reference_magnification: 1.0,
803            marks: vec![
804                ReticleMark::new(0.0, 0.0, MarkKind::Center),
805                ReticleMark::new(2.0, 0.0, MarkKind::Hash),
806                ReticleMark::new(4.0, 0.0, MarkKind::Hash),
807                ReticleMark::new(2.0, 1.0, MarkKind::Dot),
808                ReticleMark::new(2.0, -1.0, MarkKind::Dot),
809            ],
810        }
811    }
812
813    #[test]
814    fn ffp_marks_are_invariant_across_magnification() {
815        let reticle = ffp_ladder();
816        let a = hold_point_in_reticle(2.3, 0.4, 4.0, &reticle).unwrap();
817        let b = hold_point_in_reticle(2.3, 0.4, 25.0, &reticle).unwrap();
818        assert_eq!(a, b, "FFP hold must not depend on magnification");
819        assert_eq!(a.mark_scale, 1.0);
820    }
821
822    #[test]
823    fn sfp_marks_scale_by_reference_over_current_magnification() {
824        let reticle = sfp_two_mil_at_ten();
825
826        // At the reference magnification the marks read their etched value.
827        let scaled = reticle.scaled_marks(10.0).unwrap();
828        assert_eq!(scaled[1].down_mil, 2.0);
829
830        // Halving the magnification doubles what a mark covers: the 2 mil mark reads 4 mil.
831        let scaled = reticle.scaled_marks(5.0).unwrap();
832        assert_eq!(scaled[1].down_mil, 4.0);
833        assert_eq!(scaled[2].down_mil, 8.0);
834
835        // Doubling it halves them.
836        let scaled = reticle.scaled_marks(20.0).unwrap();
837        assert_eq!(scaled[1].down_mil, 1.0);
838    }
839
840    #[test]
841    fn sfp_nearest_mark_is_measured_in_true_angular_space() {
842        let reticle = sfp_two_mil_at_ten();
843        // At 5x the etched 2 mil mark sits at 4 mil TRUE, so a 4 mil drop lands on it
844        // exactly — the hold point itself is never rescaled.
845        let hold = hold_point_in_reticle(4.0, 0.0, 5.0, &reticle).unwrap();
846        assert_eq!(hold.nearest_mark, Some(1));
847        assert_eq!(hold.nearest_mark_distance_mil, 0.0);
848        assert_eq!(hold.down_mil, 4.0, "the hold stays TRUE angular");
849        assert_eq!(hold.mark_scale, 2.0);
850
851        // The same 4 mil drop at the reference magnification lands on the 4 mil mark.
852        let hold = hold_point_in_reticle(4.0, 0.0, 10.0, &reticle).unwrap();
853        assert_eq!(hold.nearest_mark, Some(2));
854        assert_eq!(hold.nearest_mark_distance_mil, 0.0);
855    }
856
857    #[test]
858    fn hold_on_a_mark_has_zero_distance() {
859        let reticle = ffp_ladder();
860        let hold = hold_point_in_reticle(2.0, 1.0, 10.0, &reticle).unwrap();
861        assert_eq!(hold.nearest_mark, Some(3));
862        assert_eq!(hold.nearest_mark_distance_mil, 0.0);
863        assert!(!hold.off_reticle);
864    }
865
866    #[test]
867    fn off_reticle_boundary_follows_the_documented_margin() {
868        let reticle = ffp_ladder();
869        // down span 0..4 => margin 0.8; right span -1..1 => margin 0.4.
870        let inside = hold_point_in_reticle(4.8, 0.0, 10.0, &reticle).unwrap();
871        assert!(!inside.off_reticle, "exactly on the margin counts as on-reticle");
872        let outside = hold_point_in_reticle(4.80001, 0.0, 10.0, &reticle).unwrap();
873        assert!(outside.off_reticle);
874
875        let inside = hold_point_in_reticle(2.0, 1.4, 10.0, &reticle).unwrap();
876        assert!(!inside.off_reticle);
877        let outside = hold_point_in_reticle(2.0, 1.40001, 10.0, &reticle).unwrap();
878        assert!(outside.off_reticle);
879
880        // Above center is off the ladder too (span starts at 0.0).
881        assert!(hold_point_in_reticle(-0.9, 0.0, 10.0, &reticle).unwrap().off_reticle);
882    }
883
884    #[test]
885    fn sfp_off_reticle_uses_the_scaled_bounding_box() {
886        let reticle = sfp_two_mil_at_ten();
887        // At 5x the ladder reaches 8 mil TRUE (margin 1.6), so a 9 mil hold is still on.
888        assert!(!hold_point_in_reticle(9.0, 0.0, 5.0, &reticle).unwrap().off_reticle);
889        // At 20x it reaches only 2 mil TRUE (margin 0.4), so the same hold is far off.
890        assert!(hold_point_in_reticle(9.0, 0.0, 20.0, &reticle).unwrap().off_reticle);
891    }
892
893    #[test]
894    fn rejects_non_physical_magnification_on_both_planes() {
895        for reticle in [ffp_ladder(), sfp_two_mil_at_ten()] {
896            assert_eq!(
897                hold_point_in_reticle(1.0, 0.0, 0.0, &reticle),
898                Err(ReticleError::NonPositiveMagnification { magnification: 0.0 })
899            );
900            assert!(matches!(
901                hold_point_in_reticle(1.0, 0.0, -3.0, &reticle),
902                Err(ReticleError::NonPositiveMagnification { .. })
903            ));
904            assert!(matches!(
905                hold_point_in_reticle(1.0, 0.0, f64::NAN, &reticle),
906                Err(ReticleError::NonPositiveMagnification { .. })
907            ));
908        }
909    }
910
911    #[test]
912    fn sfp_rejects_a_non_positive_reference_magnification_but_ffp_ignores_it() {
913        let mut sfp = sfp_two_mil_at_ten();
914        sfp.reference_magnification = 0.0;
915        assert!(matches!(
916            hold_point_in_reticle(1.0, 0.0, 10.0, &sfp),
917            Err(ReticleError::NonPositiveReferenceMagnification { .. })
918        ));
919
920        let mut ffp = ffp_ladder();
921        ffp.reference_magnification = 0.0;
922        assert!(
923            hold_point_in_reticle(1.0, 0.0, 10.0, &ffp).is_ok(),
924            "FFP never consults the reference magnification"
925        );
926    }
927
928    #[test]
929    fn rejects_empty_and_non_finite_descriptions() {
930        let mut reticle = ffp_ladder();
931        reticle.marks.clear();
932        assert_eq!(
933            hold_point_in_reticle(1.0, 0.0, 10.0, &reticle),
934            Err(ReticleError::NoMarks)
935        );
936
937        let mut reticle = ffp_ladder();
938        reticle.marks[2].down_mil = f64::NAN;
939        assert_eq!(
940            hold_point_in_reticle(1.0, 0.0, 10.0, &reticle),
941            Err(ReticleError::NonFiniteMark { index: 2 })
942        );
943
944        let reticle = ffp_ladder();
945        assert!(matches!(
946            hold_point_in_reticle(f64::NAN, 0.0, 10.0, &reticle),
947            Err(ReticleError::NonFiniteHold { .. })
948        ));
949    }
950
951    #[test]
952    fn mil_grid_generates_a_cross_not_a_filled_grid() {
953        let reticle = ReticleDescription::mil_grid(0.5, 2.0).unwrap();
954        // 4 steps per arm plus the center.
955        assert_eq!(reticle.marks.len(), 1 + 4 * 4);
956        assert_eq!(reticle.marks[0].kind, MarkKind::Center);
957        // Every non-center mark lies on exactly one axis — no (down, right) pair is both
958        // non-zero, which is what distinguishes a cross from a grid.
959        for mark in &reticle.marks[1..] {
960            assert!(
961                mark.down_mil == 0.0 || mark.right_mil == 0.0,
962                "mil_grid must not produce off-axis marks"
963            );
964        }
965        assert_eq!(reticle.focal_plane, FocalPlane::First);
966    }
967
968    #[test]
969    fn tree_widens_one_step_per_row() {
970        let reticle = ReticleDescription::tree(3, 1.0, 0.5).unwrap();
971        // center + per row (1 on-axis + 2*row dots) = 1 + (1+2) + (1+4) + (1+6)
972        assert_eq!(reticle.marks.len(), 1 + 3 + 2 * (1 + 2 + 3));
973        let widest_in_row = |down: f64| {
974            reticle
975                .marks
976                .iter()
977                .filter(|m| m.down_mil == down)
978                .map(|m| m.right_mil.abs())
979                .fold(0.0_f64, f64::max)
980        };
981        assert_eq!(widest_in_row(1.0), 0.5);
982        assert_eq!(widest_in_row(2.0), 1.0);
983        assert_eq!(widest_in_row(3.0), 1.5);
984    }
985
986    #[test]
987    fn bdc_from_drops_is_pure_data_assembly() {
988        let reticle =
989            ReticleDescription::bdc_from_drops(&[(300.0, 1.2), (400.0, 2.4), (500.0, 4.1)]).unwrap();
990        assert_eq!(reticle.marks.len(), 4);
991        assert_eq!(reticle.marks[0].kind, MarkKind::Center);
992        assert_eq!(reticle.marks[1].down_mil, 1.2);
993        assert_eq!(reticle.marks[1].label.as_deref(), Some("300 m"));
994        assert_eq!(reticle.marks[3].down_mil, 4.1);
995        // No windage component is invented.
996        assert!(reticle.marks.iter().all(|m| m.right_mil == 0.0));
997    }
998
999    #[test]
1000    fn generators_reject_bad_parameters() {
1001        assert!(ReticleDescription::mil_grid(0.0, 5.0).is_err());
1002        assert!(ReticleDescription::mil_grid(0.5, 0.0).is_err());
1003        assert!(ReticleDescription::mil_grid(5.0, 1.0).is_err());
1004        assert!(ReticleDescription::tree(0, 1.0, 0.5).is_err());
1005        assert!(ReticleDescription::tree(3, -1.0, 0.5).is_err());
1006        assert!(ReticleDescription::bdc_from_drops(&[]).is_err());
1007        assert!(ReticleDescription::bdc_from_drops(&[(0.0, 1.0)]).is_err());
1008        assert!(ReticleDescription::bdc_from_drops(&[(100.0, f64::NAN)]).is_err());
1009        // The mark cap is enforced before a runaway grid is materialized.
1010        assert!(matches!(
1011            ReticleDescription::mil_grid(0.001, 10.0),
1012            Err(ReticleError::TooManyMarks { .. })
1013        ));
1014    }
1015
1016    #[test]
1017    fn generator_sizes_cannot_overflow_past_the_cap() {
1018        // A step/row count large enough that the pre-cap `1 + 4*steps` (or the tree
1019        // product) WRAPS usize back below MAX_RETICLE_MARKS. Before the checked-size
1020        // guard these returned Ok and the generator loop ran unbounded (OOM/hang in
1021        // release, multiply-overflow panic in debug). Now they are rejected.
1022        let huge = (usize::MAX / 2) as f64; // exactly representable, cast without saturation
1023        assert!(matches!(
1024            ReticleDescription::mil_grid(1.0, huge),
1025            Err(ReticleError::TooManyMarks { .. })
1026        ));
1027        assert!(matches!(
1028            ReticleDescription::tree(usize::MAX / 2, 1.0, 0.5),
1029            Err(ReticleError::TooManyMarks { .. })
1030        ));
1031        // And the ordinary over-cap case (no overflow) still reports cleanly.
1032        assert!(matches!(
1033            ReticleDescription::tree(1000, 1.0, 0.5),
1034            Err(ReticleError::TooManyMarks { .. })
1035        ));
1036    }
1037
1038    #[test]
1039    fn description_round_trips_through_serde() {
1040        let reticle = ReticleDescription {
1041            name: "round trip".to_string(),
1042            focal_plane: FocalPlane::Second,
1043            reference_magnification: 12.0,
1044            marks: vec![
1045                ReticleMark::new(0.0, 0.0, MarkKind::Center),
1046                ReticleMark::labeled(3.5, -1.5, MarkKind::Dot, "600 yd"),
1047                ReticleMark::new(6.0, 0.0, MarkKind::Post),
1048            ],
1049        };
1050        let json = serde_json::to_string(&reticle).unwrap();
1051        assert!(json.contains("\"sfp\""), "focal plane serializes as sfp: {json}");
1052        assert!(json.contains("\"center\""));
1053        let back: ReticleDescription = serde_json::from_str(&json).unwrap();
1054        assert_eq!(back, reticle);
1055
1056        // An unlabeled mark emits no `label` key at all.
1057        let json = serde_json::to_string(&ReticleMark::new(1.0, 0.0, MarkKind::Hash)).unwrap();
1058        assert!(!json.contains("label"), "{json}");
1059
1060        // Unknown keys are tolerated (front ends may carry render metadata).
1061        let permissive: ReticleDescription = serde_json::from_str(
1062            r#"{"name":"x","focal_plane":"ffp","reference_magnification":1.0,
1063                "marks":[{"down_mil":1.0,"right_mil":0.0,"kind":"hash"}],"stroke":"thin"}"#,
1064        )
1065        .unwrap();
1066        assert_eq!(permissive.marks.len(), 1);
1067    }
1068
1069    #[test]
1070    fn hold_round_trips_through_serde() {
1071        let reticle = ffp_ladder();
1072        let hold = hold_point_in_reticle(3.1, 0.6, 10.0, &reticle).unwrap();
1073        let back: ReticleHold = serde_json::from_str(&serde_json::to_string(&hold).unwrap()).unwrap();
1074        assert_eq!(back, hold);
1075    }
1076
1077    #[test]
1078    fn formatters_are_stable_and_json_is_the_schema() {
1079        let reticle = ReticleDescription::bdc_from_drops(&[(300.0, 1.2)]).unwrap();
1080        let json = format_reticle_description(&reticle, ReticleFormat::Json);
1081        assert!(json.ends_with('\n'));
1082        let back: ReticleDescription = serde_json::from_str(&json).unwrap();
1083        assert_eq!(back, reticle, "generate -o json feeds hold --reticle-json");
1084
1085        let hold = hold_point_in_reticle(1.2, 0.0, 10.0, &reticle).unwrap();
1086        let table = format_reticle_hold(&hold, &reticle, 10.0, ReticleFormat::Table);
1087        assert!(table.contains("Hold down:"));
1088        assert!(table.contains("300 m"));
1089        assert!(!table.contains("Subtension scale"), "FFP hides SFP-only rows");
1090
1091        let sfp = sfp_two_mil_at_ten();
1092        let hold = hold_point_in_reticle(4.0, 0.0, 5.0, &sfp).unwrap();
1093        let table = format_reticle_hold(&hold, &sfp, 5.0, ReticleFormat::Table);
1094        assert!(table.contains("Subtension scale: 2.0000x"));
1095    }
1096}