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