ballistics_engine/
reticle_catalog.rs

1//! Named reticles, by id — the tracked follow-up [`crate::reticle`]'s header defers to.
2//!
3//! `reticle` deliberately has no catalog: it ships three generic constructors and says so,
4//! because "manufacturer subtension sheets are published facts and are a legally viable
5//! catalog source, but curating one is a separate, per-vendor IP-reviewed data project".
6//! This module is that project, kept separate so that statement stays true of the module
7//! it was made about.
8//!
9//! # Why a catalog is the whole feature (MBA-1545)
10//!
11//! A holdover display is only worth anything if it shows the shooter THEIR reticle. A hold
12//! against a generic mil grid reads "hold 4.2 mil down", which is what a range card
13//! already prints — the same number, worse. The value is "hold on the third dot down, two
14//! right", and that needs the marks actually etched in their glass.
15//!
16//! Nothing public supplies those. Surveyed 2026-09-17: the `.reticle` XML format
17//! (gehtsoft-usa/BallisticCalculator1, LGPL-2.1) ships a designer and worked examples, not
18//! commercial designs; `o-murphy/bcrg` (LGPL-3.0) generates reticles procedurally and emits
19//! 1-bit BMPs, which carry no coordinates and cannot drive hold math; and no
20//! machine-readable dataset of real reticles appears to exist. The real source is
21//! manufacturer subtension sheets, transcribed by hand.
22//!
23//! # The two constraints, which are different constraints
24//!
25//! **Licensing.** Both open projects in this space are LGPL and this crate is MIT OR
26//! Apache-2.0. Every entry here must be OUR transcription of published subtension facts —
27//! measurements, which are not copyrightable — and never a file lifted from an LGPL
28//! project. This is the same reason `reticle_import` and `profile_import` are cleanroom
29//! implementations written from format specifications.
30//!
31//! **Patents.** Horus grid reticles and Time-of-Flight Wind Dots are actively patented.
32//! That bars reproducing those LAYOUTS however the numbers were obtained, and it is not a
33//! data-licensing question — a patented layout stays barred even where its subtensions are
34//! published. No TREMOR-family or Horus geometry enters this module, on the same terms
35//! [`crate::reticle`] states for itself.
36//!
37//! # Provenance is mandatory, because a wrong subtension is a silently wrong hold
38//!
39//! Every [`CatalogEntry`] carries a [`CatalogEntry::source`] naming where its geometry came
40//! from. That is not decoration. A mistyped subtension produces a hold that is wrong by a
41//! mark and looks entirely normal — there is no downstream check that can catch it, the
42//! same property that makes a grams-for-grains bullet weight invisible (MBA-1521). Naming
43//! the source per entry is what makes a bad number traceable to a document someone can
44//! re-read, rather than anonymous.
45//!
46//! So this module starts with what can be stated EXACTLY from a public standard, and each
47//! vendor reticle added later is its own change citing its own sheet.
48
49use crate::reticle::{FocalPlane, MarkKind, ReticleDescription, ReticleError, ReticleMark};
50
51/// One named reticle this build can produce, and where its geometry came from.
52#[derive(Debug, Clone, PartialEq)]
53pub struct CatalogEntry {
54    /// Stable wire id — lowercase, hyphenated. Callers persist this, so it does not change.
55    pub id: &'static str,
56    /// What a picker shows.
57    pub display_name: &'static str,
58    /// Where the subtensions came from, specifically enough to re-check. Mandatory; see
59    /// the module header.
60    pub source: &'static str,
61    /// What a shooter should know before trusting it — variations between scopes that
62    /// carry this reticle, and anything the model does not capture.
63    pub notes: &'static str,
64}
65
66/// The classic mil-dot, at its standard subtensions.
67///
68/// Dots centred every **1 mil** along both stadia, each dot subtending **0.2 mil**. That
69/// spacing is the standard and is what a hold is read against; it is why the reticle is
70/// called what it is, and it does not vary between the scopes that carry it.
71///
72/// `dots_per_side` is how many dots are etched out from centre on each arm, and it DOES
73/// vary — four and five are both common, and some scopes suppress the innermost dot. It is
74/// a parameter rather than a constant for that reason, and it changes nothing about a hold
75/// that falls inside the ladder: the marks are at the same angles either way, there are
76/// just more or fewer of them.
77///
78/// The dot DIAMETER is carried in the docs rather than the model because
79/// [`ReticleDescription`] positions marks and does not size them — a hold is measured to a
80/// mark's centre. A 0.2 mil dot is worth knowing when reading a hold off glass (it is its
81/// own coarse ranging reference) and changes no arithmetic here.
82///
83/// FFP, with `reference_magnification` 1.0, which is unused on that plane. A mil-dot etched
84/// in the second focal plane is the same geometry at its own reference magnification;
85/// callers set those two fields, exactly as they do for a generated reticle.
86pub fn mil_dot(dots_per_side: usize) -> Result<ReticleDescription, ReticleError> {
87    if dots_per_side == 0 {
88        return Err(ReticleError::InvalidGeneratorParameter {
89            parameter: "dots_per_side",
90            value: 0.0,
91            rule: "at least one dot on each arm",
92        });
93    }
94
95    let mut marks = Vec::with_capacity(1 + dots_per_side * 4);
96    marks.push(ReticleMark::new(0.0, 0.0, MarkKind::Center));
97    for n in 1..=dots_per_side {
98        let mil = n as f64;
99        // Down and up the vertical stadium, then right and left the horizontal one. Labels
100        // name the angle, which is the only thing a shooter reads a mil-dot for.
101        marks.push(ReticleMark::labeled(
102            mil,
103            0.0,
104            MarkKind::Dot,
105            format!("{n} mil down"),
106        ));
107        marks.push(ReticleMark::labeled(
108            -mil,
109            0.0,
110            MarkKind::Dot,
111            format!("{n} mil up"),
112        ));
113        marks.push(ReticleMark::labeled(
114            0.0,
115            mil,
116            MarkKind::Dot,
117            format!("{n} mil right"),
118        ));
119        marks.push(ReticleMark::labeled(
120            0.0,
121            -mil,
122            MarkKind::Dot,
123            format!("{n} mil left"),
124        ));
125    }
126
127    Ok(ReticleDescription {
128        name: format!("Mil-Dot ({dots_per_side} per side)"),
129        focal_plane: FocalPlane::First,
130        reference_magnification: 1.0,
131        marks,
132    })
133}
134
135/// How many dots per arm [`MIL_DOT_ID`] builds when no count is asked for.
136///
137/// Five, which reaches 5 mil — far enough for the holds this is used for, and a count real
138/// scopes carry. See [`mil_dot`] on why this is a convention and the 1 mil spacing is not.
139pub const MIL_DOT_DEFAULT_DOTS_PER_SIDE: usize = 5;
140
141/// Wire id of the mil-dot entry.
142pub const MIL_DOT_ID: &str = "mil-dot";
143
144/// A plain hash ladder: marks every `spacing` along both stadia out to `extent`.
145///
146/// GENERIC GEOMETRY, and that is the whole point of these entries. A hash ladder
147/// is not anybody's design — it is what a reticle looks like when it is just a
148/// ruler, and a great many scopes carry exactly that under a vendor's name. So
149/// these can be stated exactly, from the spacing alone, without transcribing
150/// anyone's subtension sheet and without reproducing anyone's layout.
151///
152/// `unit_label` names the angular unit the spacing is quoted in, for the display
153/// name only. The geometry is milliradians either way, because
154/// [`ReticleMark`] is milliradians — an MOA ladder is a ladder whose marks
155/// happen to fall on MOA multiples, not a different kind of object.
156fn hash_ladder(
157    name: String,
158    spacing_mil: f64,
159    extent_mil: f64,
160) -> Result<ReticleDescription, ReticleError> {
161    let mut description = ReticleDescription::mil_grid(spacing_mil, extent_mil)?;
162    description.name = name;
163    Ok(description)
164}
165
166/// One MOA in milliradians, on the engine's locked printed-table constant.
167///
168/// `adjustment.rs` pins MOA at 3438 rather than the exact-angle 3437.7467 and
169/// prints every MOA column on it (MBA-724), so an MOA ladder built here has to
170/// use the same number or its marks sit a hair off the dial column beside them.
171/// 1 MOA = 1000/3438 mil.
172const MIL_PER_MOA: f64 = 1000.0 / 3438.0;
173
174/// Wire id of the half-mil hash ladder.
175pub const MIL_HASH_HALF_ID: &str = "mil-hash-0.5";
176
177/// Wire id of the fine two-tenth mil hash ladder.
178pub const MIL_HASH_FINE_ID: &str = "mil-hash-0.2";
179
180/// Wire id of the one-MOA hash ladder.
181pub const MOA_HASH_1_ID: &str = "moa-hash-1";
182
183/// Wire id of the two-MOA hash ladder.
184pub const MOA_HASH_2_ID: &str = "moa-hash-2";
185
186/// Every named reticle in this build.
187///
188/// Deliberately short. It holds what can be stated EXACTLY from a public standard; vendor
189/// reticles arrive one change at a time, each citing the sheet it was transcribed from, so
190/// that a bad subtension is traceable. An entry whose `source` would have to read "from
191/// memory" does not belong here at all — see the module header.
192pub fn catalog() -> Vec<CatalogEntry> {
193    vec![
194        CatalogEntry {
195            id: MIL_DOT_ID,
196            display_name: "Mil-Dot",
197            source: "US military mil-dot standard: dots on 1 mil centres, 0.2 mil dot \
198                 subtension. Long-published and carried unchanged by every scope that \
199                 names the reticle; no vendor sheet is involved.",
200            notes: "The 1 mil spacing is the standard and does not vary. How many dots are \
201                etched on each arm DOES vary between scopes (four and five are both \
202                common); this builds five, and a hold inside the ladder reads the same \
203                either way. Dots subtend 0.2 mil, which this model does not draw — marks \
204                are positions, and a hold is measured to a mark's centre.",
205        },
206        CatalogEntry {
207            id: MIL_HASH_HALF_ID,
208            display_name: "Mil hash, 0.5 mil",
209            source: "Generic geometry, not a vendor design: hash marks on 0.5 mil \
210                 centres along both stadia. Stated from the spacing alone — \
211                 nothing here is transcribed from anyone's subtension sheet.",
212            notes: "Many scopes carry a plain half-mil ladder under a vendor name. \
213                Use this when yours is simply a ruler; if it has a tree, \
214                irregular windage marks or labelled BDC marks, it is not this \
215                and the holds will name the wrong mark.",
216        },
217        CatalogEntry {
218            id: MIL_HASH_FINE_ID,
219            display_name: "Mil hash, 0.2 mil",
220            source: "Generic geometry, not a vendor design: hash marks on 0.2 mil \
221                 centres along both stadia.",
222            notes: "A fine ladder, typical of a first-focal-plane target reticle at \
223                high magnification. Same caveat as the half-mil ladder: this is \
224                a ruler, not a tree.",
225        },
226        CatalogEntry {
227            id: MOA_HASH_1_ID,
228            display_name: "MOA hash, 1 MOA",
229            source: "Generic geometry, not a vendor design: hash marks on 1 MOA \
230                 centres along both stadia, converted at the engine's locked \
231                 printed-table 3438 MOA per radian (adjustment.rs, MBA-724) so \
232                 the marks agree with the MOA column beside them.",
233            notes: "An MOA ladder is a ladder whose marks fall on MOA multiples; \
234                the model is milliradians either way. 1 MOA is about 0.29 mil, \
235                so these sit closer together than a mil ladder's.",
236        },
237        CatalogEntry {
238            id: MOA_HASH_2_ID,
239            display_name: "MOA hash, 2 MOA",
240            source: "Generic geometry, not a vendor design: hash marks on 2 MOA \
241                 centres along both stadia, on the same locked 3438 constant.",
242            notes: "The coarser of the two MOA ladders, and the commoner one on a \
243                hunting scope.",
244        },
245    ]
246}
247
248/// Build the reticle named by `id`, or `None` when this build has no such entry.
249///
250/// `None` rather than a default: a caller asking for a specific reticle and silently
251/// getting a different one would be shown holds for glass they are not looking through.
252pub fn by_id(id: &str) -> Option<Result<ReticleDescription, ReticleError>> {
253    match id {
254        MIL_DOT_ID => Some(mil_dot(MIL_DOT_DEFAULT_DOTS_PER_SIDE)),
255        // Ten mils of ladder, which reaches past any hold a shooter takes on a
256        // reticle rather than a dial, and matches what mil_grid's own extent
257        // means: marks out TO this, not beyond it.
258        MIL_HASH_HALF_ID => Some(hash_ladder("Mil hash 0.5".into(), 0.5, 10.0)),
259        MIL_HASH_FINE_ID => Some(hash_ladder("Mil hash 0.2".into(), 0.2, 5.0)),
260        MOA_HASH_1_ID => Some(hash_ladder(
261            "MOA hash 1".into(),
262            MIL_PER_MOA,
263            MIL_PER_MOA * 30.0,
264        )),
265        MOA_HASH_2_ID => Some(hash_ladder(
266            "MOA hash 2".into(),
267            MIL_PER_MOA * 2.0,
268            MIL_PER_MOA * 30.0,
269        )),
270        _ => None,
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::reticle::hold_point_in_reticle;
278
279    #[test]
280    fn mil_dot_puts_its_dots_on_one_mil_centres() {
281        // The one thing about this reticle that is a standard rather than a convention.
282        let reticle = mil_dot(5).unwrap();
283        let mut down: Vec<f64> = reticle
284            .marks
285            .iter()
286            .filter(|m| m.right_mil == 0.0 && m.down_mil > 0.0)
287            .map(|m| m.down_mil)
288            .collect();
289        down.sort_by(|a, b| a.partial_cmp(b).unwrap());
290        assert_eq!(down, vec![1.0, 2.0, 3.0, 4.0, 5.0]);
291    }
292
293    #[test]
294    fn it_is_a_cross_not_a_grid() {
295        // The IP line `reticle`'s header draws, restated where a new module could cross it:
296        // marks live on the two stadia and nowhere else. A filled two-dimensional grid is
297        // patented geometry.
298        let reticle = mil_dot(5).unwrap();
299        for mark in &reticle.marks {
300            assert!(
301                mark.down_mil == 0.0 || mark.right_mil == 0.0,
302                "mark at ({}, {}) is off both stadia — that is a grid",
303                mark.down_mil,
304                mark.right_mil
305            );
306        }
307        assert_eq!(reticle.marks.len(), 1 + 5 * 4);
308    }
309
310    #[test]
311    fn a_four_dot_scope_holds_the_same_as_a_five_dot_one() {
312        // Why dots_per_side is a parameter and not a correctness question: the marks are
313        // at the same angles, there are just fewer of them.
314        let four = mil_dot(4).unwrap();
315        let five = mil_dot(5).unwrap();
316        for reticle in [&four, &five] {
317            let hold = hold_point_in_reticle(3.0, 0.0, 10.0, reticle).unwrap();
318            let mark = &reticle.marks[hold.nearest_mark.unwrap()];
319            assert_eq!(mark.down_mil, 3.0);
320            assert_eq!(hold.nearest_mark_distance_mil, 0.0);
321        }
322    }
323
324    #[test]
325    fn every_entry_names_a_source_and_builds() {
326        // Provenance is the module's rule; a test is what makes it one. An entry that
327        // cannot say where its numbers came from is exactly the entry that will be wrong
328        // and untraceable.
329        for entry in catalog() {
330            assert!(
331                !entry.source.trim().is_empty(),
332                "{} names no source",
333                entry.id
334            );
335            assert!(
336                !entry.notes.trim().is_empty(),
337                "{} carries no notes",
338                entry.id
339            );
340            assert!(!entry.display_name.trim().is_empty());
341            assert_eq!(entry.id, entry.id.to_lowercase(), "ids are lowercase");
342            let built = by_id(entry.id)
343                .unwrap_or_else(|| panic!("{} is listed but by_id does not build it", entry.id))
344                .unwrap_or_else(|e| panic!("{} does not build: {e}", entry.id));
345            built
346                .validate()
347                .unwrap_or_else(|e| panic!("{} builds an invalid reticle: {e}", entry.id));
348        }
349    }
350
351    #[test]
352    fn a_moa_ladder_falls_on_moa_multiples() {
353        // An MOA ladder is a ladder whose marks fall on MOA multiples; the model
354        // is milliradians either way. The constant is the engine's LOCKED 3438,
355        // not the exact-angle 3437.7467, so these marks agree with the MOA
356        // column printed beside them (MBA-724).
357        let reticle = by_id(MOA_HASH_1_ID).unwrap().unwrap();
358        let one_moa = 1000.0 / 3438.0;
359        let mut down: Vec<f64> = reticle
360            .marks
361            .iter()
362            .filter(|m| m.right_mil == 0.0 && m.down_mil > 0.0)
363            .map(|m| m.down_mil)
364            .collect();
365        down.sort_by(|a, b| a.partial_cmp(b).unwrap());
366        for (index, mil) in down.iter().enumerate() {
367            let expected = one_moa * (index + 1) as f64;
368            assert!(
369                (mil - expected).abs() < 1e-9,
370                "mark {index} at {mil} mil is not {} MOA",
371                index + 1
372            );
373        }
374        assert!(
375            down.len() >= 20,
376            "a 30 MOA ladder should carry 30 marks a side"
377        );
378    }
379
380    #[test]
381    fn every_ladder_is_a_cross_and_carries_its_centre() {
382        // The IP line, restated for every entry rather than only the mil-dot: a
383        // filled two-dimensional grid is patented geometry, and nothing here
384        // may drift into one.
385        for entry in catalog() {
386            let reticle = by_id(entry.id).unwrap().unwrap();
387            assert!(
388                reticle
389                    .marks
390                    .iter()
391                    .any(|m| m.kind == MarkKind::Center && m.down_mil == 0.0 && m.right_mil == 0.0),
392                "{} has no centre",
393                entry.id
394            );
395            for mark in &reticle.marks {
396                assert!(
397                    mark.down_mil == 0.0 || mark.right_mil == 0.0,
398                    "{}: mark at ({}, {}) is off both stadia — that is a grid",
399                    entry.id,
400                    mark.down_mil,
401                    mark.right_mil
402                );
403            }
404        }
405    }
406
407    #[test]
408    fn the_generic_ladders_say_they_are_generic() {
409        // These are stated from a spacing, not transcribed from a sheet, and
410        // the entry has to say so — a shooter comparing this against their own
411        // glass needs to know it is a ruler and not their vendor's design.
412        for id in [
413            MIL_HASH_HALF_ID,
414            MIL_HASH_FINE_ID,
415            MOA_HASH_1_ID,
416            MOA_HASH_2_ID,
417        ] {
418            let entry = catalog().into_iter().find(|e| e.id == id).unwrap();
419            assert!(
420                entry
421                    .source
422                    .contains("Generic geometry, not a vendor design"),
423                "{id} does not say it is generic"
424            );
425        }
426    }
427
428    #[test]
429    fn an_unknown_id_is_none_rather_than_a_substitute() {
430        // Silently handing back a different reticle would show a shooter holds for glass
431        // they are not looking through.
432        assert!(by_id("no-such-reticle").is_none());
433        assert!(by_id("").is_none());
434        assert!(
435            by_id("MIL-DOT").is_none(),
436            "ids are case-sensitive on the wire"
437        );
438    }
439
440    #[test]
441    fn zero_dots_is_refused() {
442        assert!(mil_dot(0).is_err());
443    }
444}