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}