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