Skip to main content

ballistics_engine/
adjustment.rs

1//! Turret adjustment-unit conversions and click-value parsing (MBA-1355).
2//!
3//! Shared by the CLI (`main.rs`) and the WASM terminal (`wasm.rs`). This lives in the
4//! library crate — not `main.rs` — so it compiles for `wasm32-unknown-unknown` with no
5//! feature gate. To avoid a circular dependency on `main.rs`'s clap `AdjustmentUnit`
6//! `ValueEnum`, this module defines its own minimal angular base (`ClickBase`) and factor
7//! table; `main.rs` maps its own `AdjustmentUnit` onto `ClickBase`/`adjustment_factor`
8//! locally (see `drop_to_adjustment` in `main.rs`).
9
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11
12/// Angular base unit for a turret click graduation (MBA-1355). A click graduation is
13/// always expressed in mil, (true) MOA, or SMOA/IPHY — never in whole clicks itself.
14///
15/// Does not itself derive `Serialize`/`Deserialize`: `ClickValue` serializes as ONE
16/// suffixed string (`"0.1mil"`, `"0.25moa"`, ...) via its own hand-written impls below,
17/// not as a structural `{size, base}` object, so nothing needs this type's wire form on
18/// its own (MBA-1348).
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub enum ClickBase {
21    Mil,
22    Moa,
23    Smoa,
24}
25
26/// The `(drop_yd / range_yd) * factor` conversion factor for a `ClickBase`:
27/// - `Mil`: 1000.0 (milliradians)
28/// - `Moa`: 3438.0 (true MOA; this crate's locked printed-table constant — see MBA-724 —
29///   deliberately not the exact-angle 3437.7467)
30/// - `Smoa`: 3600.0 ("shooter's MOA" / inches-per-hundred-yards; exact by definition)
31pub fn adjustment_factor(base: ClickBase) -> f64 {
32    match base {
33        ClickBase::Mil => 1000.0,
34        ClickBase::Moa => 3438.0,
35        ClickBase::Smoa => 3600.0,
36    }
37}
38
39/// A turret click graduation, parsed from suffixed CLI/profile syntax
40/// like "0.1mil" / "0.25moa" / "0.125smoa" (MBA-1355).
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct ClickValue {
43    pub size: f64,
44    pub base: ClickBase,
45}
46
47/// Parses a suffixed click graduation string. The unit suffix is mandatory — one of
48/// `mil`, `moa`, `smoa`, `iphy` (`iphy` is accepted as an alias for `smoa`, the identical
49/// unit) — and the magnitude must be a positive, finite number.
50pub fn parse_click_value(s: &str) -> Result<ClickValue, String> {
51    let t = s.trim().to_ascii_lowercase();
52    let (num, base) = if let Some(n) = t.strip_suffix("smoa") {
53        (n, ClickBase::Smoa)
54    } else if let Some(n) = t.strip_suffix("iphy") {
55        (n, ClickBase::Smoa)
56    } else if let Some(n) = t.strip_suffix("moa") {
57        (n, ClickBase::Moa)
58    } else if let Some(n) = t.strip_suffix("mil") {
59        (n, ClickBase::Mil)
60    } else {
61        return Err(format!(
62            "click value '{s}' needs a unit suffix: mil, moa, smoa, or iphy (e.g. 0.1mil, 0.25moa)"
63        ));
64    };
65    let size: f64 = num
66        .trim()
67        .parse()
68        .map_err(|_| format!("click value '{s}' has an invalid number '{num}'"))?;
69    if !size.is_finite() || size <= 0.0 {
70        return Err(format!(
71            "click value '{s}' must be a positive, finite graduation"
72        ));
73    }
74    Ok(ClickValue { size, base })
75}
76
77/// Serializes as the crate's one existing suffixed-string representation — `"0.1mil"`,
78/// `"0.25moa"`, `"1smoa"` — the exact shape `parse_click_value` parses and a CLI
79/// `--elevation-click` flag already accepts (MBA-1348). Deliberately NOT the structural
80/// `{"size": 0.1, "base": "mil"}` a plain derive would produce: the string form is stable
81/// against internal field changes and makes a profile file's click entries identical to
82/// the CLI flag syntax a shooter already types. `size` uses `f64`'s default `Display`,
83/// which is Rust's shortest string that round-trips back to the same value (so `1.0`
84/// prints as `"1"`, matching `parse_click_value`'s own accepted syntax).
85impl Serialize for ClickValue {
86    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
87    where
88        S: Serializer,
89    {
90        let suffix = match self.base {
91            ClickBase::Mil => "mil",
92            ClickBase::Moa => "moa",
93            ClickBase::Smoa => "smoa",
94        };
95        let size = self.size;
96        serializer.serialize_str(&format!("{size}{suffix}"))
97    }
98}
99
100/// Deserializes via `parse_click_value` — the ONLY parser for this syntax, not a second,
101/// divergent implementation of it — so a malformed string is rejected with the identical
102/// message the CLI already gives, and `parse_click_value`'s positive-and-finite rule
103/// applies on every deserialize (MBA-1348).
104impl<'de> Deserialize<'de> for ClickValue {
105    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
106    where
107        D: Deserializer<'de>,
108    {
109        let raw = String::deserialize(deserializer)?;
110        parse_click_value(&raw).map_err(serde::de::Error::custom)
111    }
112}
113
114/// One quantized dial setting: the detent count nearest `angle`, and what remains.
115/// `residual` is in the same angular unit as `angle` (the click's base unit) and is
116/// DEFINED by the exact reconstruction identity
117/// `angle == clicks as f64 * click.size + residual` (bit-for-bit, by construction).
118#[derive(Debug, Clone, Copy, PartialEq)]
119pub struct Quantized {
120    pub clicks: i64,
121    pub residual: f64,
122}
123
124/// Quantize an angular value (in `click.base` units) onto a click detent.
125/// Rounding is nearest, ties away from zero — identical to `clicks_for`, which delegates here.
126pub fn quantize_angle(angle: f64, click: &ClickValue) -> Quantized {
127    let clicks_f = (angle / click.size).round();
128    Quantized {
129        clicks: clicks_f as i64,
130        residual: angle - clicks_f * click.size,
131    }
132}
133
134/// This click's size in milliradians, the crate's true-angular unit. `adjustment_factor`
135/// maps one physical ratio into each display unit, so conversion is a factor ratio —
136/// using the LOCKED printed-table MOA constant (3438; MBA-724), deliberately not 3437.7467.
137pub fn click_size_mil(click: &ClickValue) -> f64 {
138    click.size * adjustment_factor(ClickBase::Mil) / adjustment_factor(click.base)
139}
140
141/// Whole-click adjustment for a drop at a range: the angular value in the graduation's
142/// own base unit divided by the graduation, rounded to the nearest click (ties away from
143/// zero). Sign is preserved. Ranges under 1 yard are defined as zero adjustment, matching
144/// the CLI's `drop_to_adjustment` short-range guard.
145pub fn clicks_for(drop_yd: f64, range_yd: f64, click: &ClickValue) -> i64 {
146    if range_yd < 1.0 {
147        return 0;
148    }
149    let angle = (drop_yd / range_yd) * adjustment_factor(click.base);
150    quantize_angle(angle, click).clicks
151}
152
153/// Zero-banner dial values `(MOA, mrad)` for a solved zero angle, corrected by the
154/// elevation tracking correction factor (MBA-1358).
155///
156/// Direction: the stored CF is the published tall-target ratio `actual measured travel
157/// / dialed travel` (0.95 = the scope under-tracks by 5%). To obtain a true angular
158/// need `N` on a scope whose dialed unit delivers `CF` true units, the number set on
159/// the dial must be `N / CF` — so dial-unit OUTPUTS are DIVIDED by the CF.
160///
161/// Replicates the WASM terminal's historical banner conversions bit-for-bit at
162/// `elevation_cf == 1.0`: `deg * 60.0` (true MOA — deliberately NOT this module's locked
163/// printed-table `Moa = 3438.0` factor; see the MBA-724 note in the wasm banner sites)
164/// and `rad * 1000.0`, each divided by exactly `1.0` (a bit-exact no-op). Lives here,
165/// in an ungated module, so the emit rule is host-testable (`wasm.rs` is wasm32-gated)
166/// and shared by all three banner sites instead of being edited in parallel.
167pub fn zero_banner_dial_values(angle_deg: f64, angle_rad: f64, elevation_cf: f64) -> (f64, f64) {
168    (
169        angle_deg * 60.0 / elevation_cf,
170        angle_rad * 1000.0 / elevation_cf,
171    )
172}
173
174/// MBA-1358: the ONE accepted band for a scope tracking correction factor, shared by the
175/// native CLI and the WASM terminal so the two surfaces cannot drift: strictly between
176/// 0.5 and 1.5 (a real scope does not mistrack by 50% — values outside the band mean the
177/// tall-target test or a hand edit went wrong) and finite.
178pub fn tracking_cf_in_range(value: f64) -> bool {
179    value.is_finite() && value > 0.5 && value < 1.5
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    // MBA-1358: the WASM zero-banner emit rule, host-tested here because wasm.rs is
187    // wasm32-gated (the drag_coefficient_json_value pattern).
188    #[test]
189    fn zero_banner_dial_values_divide_by_the_elevation_cf_and_are_exact_at_one() {
190        let deg = 0.0717_f64;
191        let rad = deg.to_radians();
192        // cf = 1.0 is bit-exact against the historical inline expressions.
193        let (moa, mrad) = zero_banner_dial_values(deg, rad, 1.0);
194        assert_eq!(moa.to_bits(), (deg * 60.0).to_bits());
195        assert_eq!(mrad.to_bits(), (rad * 1000.0).to_bits());
196        // an under-tracking scope (CF < 1) needs MORE dial: outputs divide by the CF.
197        let (moa_cf, mrad_cf) = zero_banner_dial_values(deg, rad, 0.95);
198        assert_eq!(moa_cf.to_bits(), (deg * 60.0 / 0.95).to_bits());
199        assert_eq!(mrad_cf.to_bits(), (rad * 1000.0 / 0.95).to_bits());
200        assert!(moa_cf > moa && mrad_cf > mrad);
201    }
202
203    #[test]
204    fn parse_click_value_accepts_suffixed_and_rejects_bare() {
205        let c = parse_click_value("0.25moa").unwrap();
206        assert!((c.size - 0.25).abs() < 1e-12);
207        assert!(matches!(c.base, ClickBase::Moa));
208        assert!(matches!(
209            parse_click_value("0.1mil").unwrap().base,
210            ClickBase::Mil
211        ));
212        assert!(matches!(
213            parse_click_value("0.125smoa").unwrap().base,
214            ClickBase::Smoa
215        ));
216        assert!(matches!(
217            parse_click_value("0.125iphy").unwrap().base,
218            ClickBase::Smoa
219        ));
220        for bad in ["0.25", "moa", "-0.1mil", "0mil", "0.1mils", ""] {
221            assert!(parse_click_value(bad).is_err(), "{bad:?} must be rejected");
222        }
223        // error message names the accepted suffixes
224        let e = parse_click_value("0.25").unwrap_err();
225        assert!(e.contains("mil") && e.contains("moa"), "{e}");
226    }
227
228    #[test]
229    fn clicks_round_to_nearest_whole_click() {
230        let quarter_moa = parse_click_value("0.25moa").unwrap();
231        // 2.6 TMOA of drop -> 10.4 clicks -> 10
232        let drop_yd = 2.6 / 3438.0 * 100.0;
233        assert_eq!(clicks_for(drop_yd, 100.0, &quarter_moa), 10);
234        // negative (wind left) rounds symmetrically
235        assert_eq!(clicks_for(-drop_yd, 100.0, &quarter_moa), -10);
236        // 10.5 clicks rounds away from zero
237        let drop_yd = 2.625 / 3438.0 * 100.0;
238        assert_eq!(clicks_for(drop_yd, 100.0, &quarter_moa), 11);
239    }
240
241    #[test]
242    fn quantize_reconstruction_identity_is_bit_exact() {
243        // residual is DEFINED as angle - clicks*size, so reconstruction is exact by construction.
244        let c = ClickValue {
245            size: 0.25,
246            base: ClickBase::Moa,
247        };
248        for angle in [
249            0.0,
250            0.24,
251            0.25,
252            0.26,
253            -std::f64::consts::PI,
254            7.4499999,
255            1234.567,
256        ] {
257            let q = quantize_angle(angle, &c);
258            assert_eq!(
259                q.clicks as f64 * c.size + q.residual,
260                angle,
261                "identity broke at {angle}"
262            );
263            assert!(
264                q.residual.abs() <= c.size / 2.0 + f64::EPSILON,
265                "residual beyond half-click"
266            );
267        }
268    }
269
270    #[test]
271    fn quantize_exact_multiples_have_residual_exactly_zero() {
272        let c = ClickValue {
273            size: 0.1,
274            base: ClickBase::Mil,
275        };
276        let q = quantize_angle(0.1 * 27.0, &c);
277        assert_eq!(q.clicks, 27);
278        assert_eq!(q.residual, 0.0); // bit-exact, not approx
279    }
280
281    #[test]
282    fn quantize_ties_round_away_from_zero_matching_clicks_for() {
283        let c = ClickValue {
284            size: 0.5,
285            base: ClickBase::Mil,
286        };
287        assert_eq!(quantize_angle(0.25, &c).clicks, 1); // f64::round: ties away from zero
288        assert_eq!(quantize_angle(-0.25, &c).clicks, -1);
289    }
290
291    #[test]
292    fn clicks_for_is_unchanged_and_delegates() {
293        // Historical behavior pinned: sub-1yd guard + rounding. Values chosen off any tie.
294        let c = ClickValue {
295            size: 0.25,
296            base: ClickBase::Moa,
297        };
298        assert_eq!(clicks_for(10.0, 0.5, &c), 0); // range_yd < 1.0 guard
299        let angle = (7.2 / 300.0) * adjustment_factor(ClickBase::Moa);
300        assert_eq!(
301            clicks_for(7.2, 300.0, &c),
302            (angle / 0.25_f64).round() as i64
303        );
304    }
305
306    #[test]
307    fn click_size_mil_converts_via_the_locked_factor_table() {
308        let moa = ClickValue {
309            size: 0.25,
310            base: ClickBase::Moa,
311        };
312        // 0.25 MOA in mil via the LOCKED 3438 constant (MBA-724), not 3437.7467.
313        assert_eq!(click_size_mil(&moa), 0.25 * 1000.0 / 3438.0);
314        let mil = ClickValue {
315            size: 0.1,
316            base: ClickBase::Mil,
317        };
318        assert_eq!(click_size_mil(&mil), 0.1);
319    }
320
321    // MBA-1348: ClickValue's JSON wire form is the same suffixed string
322    // `parse_click_value` already parses, not a structural `{size, base}` object.
323    #[test]
324    fn click_value_json_is_pinned_to_the_suffixed_string() {
325        let quarter_moa = ClickValue {
326            size: 0.25,
327            base: ClickBase::Moa,
328        };
329        assert_eq!(serde_json::to_string(&quarter_moa).unwrap(), "\"0.25moa\"");
330
331        let tenth_mil = ClickValue {
332            size: 0.1,
333            base: ClickBase::Mil,
334        };
335        assert_eq!(serde_json::to_string(&tenth_mil).unwrap(), "\"0.1mil\"");
336
337        // A whole-number size prints without a trailing ".0" -- `f64`'s shortest Display.
338        let one_smoa = ClickValue {
339            size: 1.0,
340            base: ClickBase::Smoa,
341        };
342        assert_eq!(serde_json::to_string(&one_smoa).unwrap(), "\"1smoa\"");
343    }
344
345    #[test]
346    fn click_value_round_trips_through_json_including_smoa() {
347        let cases = [
348            ClickValue {
349                size: 0.1,
350                base: ClickBase::Mil,
351            },
352            ClickValue {
353                size: 0.25,
354                base: ClickBase::Moa,
355            },
356            ClickValue {
357                size: 1.0,
358                base: ClickBase::Smoa,
359            },
360            ClickValue {
361                size: 0.125,
362                base: ClickBase::Smoa,
363            },
364        ];
365        for click in cases {
366            let json = serde_json::to_string(&click).unwrap();
367            let parsed: ClickValue = serde_json::from_str(&json).unwrap();
368            assert_eq!(parsed, click, "round trip broke for {json}");
369        }
370    }
371
372    #[test]
373    fn click_value_deserialize_rejects_what_parse_click_value_rejects() {
374        // The Deserialize impl IS parse_click_value -- a non-positive or malformed
375        // string must fail to deserialize exactly as it fails to parse.
376        for bad in ["\"0mil\"", "\"-0.1mil\"", "\"0.25\"", "\"nonsense\""] {
377            assert!(
378                serde_json::from_str::<ClickValue>(bad).is_err(),
379                "{bad} must be rejected"
380            );
381        }
382    }
383}
384
385// ---------------------------------------------------------------------------
386// Adjustment display layer (moved from main.rs for the bridge card service).
387//
388// Everything below was previously private to the CLI. The bridge's card
389// commands need the exact same unit conversion, zero-set bias, tracking-CF,
390// and click-quantization ordering the CLI prints, so the shared boundary now
391// lives here and `main.rs` re-imports it. The module doc's historical note
392// about avoiding a circular dependency on main.rs's AdjustmentUnit no longer
393// applies: the enum itself moved here, and the CLI derives its clap face via
394// cfg_attr.
395// ---------------------------------------------------------------------------
396
397/// Printed adjustment unit for dial columns (MIL/MOA/SMOA/IPHY or whole clicks).
398///
399/// Every variant also deserializes from the UPPERCASE spelling [`adjustment_unit_label`]
400/// prints for it (MBA-1519). A card response's `units.elevation_adjustment` and
401/// `units.windage_adjustment` read `"MIL"`/`"MOA"`/`"SMOA"`/`"IPHY"`/`"CLICKS"`, and a caller
402/// that writes a card's own units block back into the next request is doing the obvious
403/// thing; before these aliases those two fields were the only ones in that block that came
404/// back as ``unknown variant `MIL` ``. Accepting a second spelling is additive on every
405/// surface that deserializes this enum (the card request's `adjustment_unit`/`windage_unit`
406/// and truing's `unit`), and what the enum PRINTS is unchanged — `Serialize` still emits the
407/// lowercase name, so no stored document or response byte moves.
408#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
409#[serde(rename_all = "lowercase")]
410#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
411pub enum AdjustmentUnit {
412    /// Milliradians (1 MIL = 3.6 inches at 100 yards)
413    #[default]
414    #[serde(alias = "MIL")]
415    Mil,
416    /// Minutes of Angle, true MOA (1 MOA = 1.047 inches at 100 yards)
417    #[serde(alias = "MOA")]
418    Moa,
419    /// Shooter's MOA (exactly 1 inch per 100 yards)
420    #[serde(alias = "SMOA")]
421    Smoa,
422    /// Inches per hundred yards (numerically identical to SMOA)
423    #[serde(alias = "IPHY")]
424    Iphy,
425    /// Whole turret clicks; requires an elevation click graduation
426    /// (--elevation-click-value or the profile's elevation_click)
427    #[serde(alias = "CLICKS")]
428    Clicks,
429}
430
431/// Convert drop to adjustment unit (MIL, MOA, or SMOA/IPHY). `unit` maps onto
432/// `ClickBase`/`adjustment_factor` (MBA-1355) so the CLI, the WASM terminal, and the
433/// bridge card service share one conversion table.
434///
435/// `AdjustmentUnit::Clicks` has no fixed factor of its own — clicks depend on a graduation
436/// (`ClickValue`) supplied separately — so callers MUST resolve clicks via `clicks_for()`
437/// before ever reaching this function with `unit == Clicks`. That arm exists only as a
438/// release-safe fallback (falls back to MIL) guarded by a `debug_assert!`.
439pub fn drop_to_adjustment(drop_yd: f64, range_yd: f64, unit: AdjustmentUnit) -> f64 {
440    if range_yd < 1.0 {
441        return 0.0;
442    }
443    let factor = match unit {
444        AdjustmentUnit::Mil => adjustment_factor(ClickBase::Mil),
445        AdjustmentUnit::Moa => adjustment_factor(ClickBase::Moa),
446        AdjustmentUnit::Smoa | AdjustmentUnit::Iphy => adjustment_factor(ClickBase::Smoa),
447        AdjustmentUnit::Clicks => {
448            // Callers resolve clicks via clicks_for() BEFORE display; reaching this
449            // arm is a scoping bug. Fall back to MIL so release builds stay sane.
450            debug_assert!(false, "Clicks must be resolved via clicks_for()");
451            adjustment_factor(ClickBase::Mil)
452        }
453    };
454    (drop_yd / range_yd) * factor
455}
456
457/// The `adjustment_factor` a non-clicks [`AdjustmentUnit`] renders in, for rescaling a
458/// MIL-denominated zero-set bias into the active display unit (MBA-1360). `Clicks`
459/// never reaches this (the clicks arm below works on raw drop); the MIL fallback
460/// mirrors `drop_to_adjustment`'s own release-safe arm.
461pub fn unit_factor_for_bias(unit: AdjustmentUnit) -> f64 {
462    match unit {
463        AdjustmentUnit::Mil => adjustment_factor(ClickBase::Mil),
464        AdjustmentUnit::Moa => adjustment_factor(ClickBase::Moa),
465        AdjustmentUnit::Smoa | AdjustmentUnit::Iphy => adjustment_factor(ClickBase::Smoa),
466        AdjustmentUnit::Clicks => {
467            debug_assert!(false, "Clicks bias is applied on raw drop, not here");
468            adjustment_factor(ClickBase::Mil)
469        }
470    }
471}
472
473/// An adjustment value plus the click-quantization detail (Plan B Task 2). `value` is
474/// the whole-click count as an `f64` on the clicks arm, the plain angle otherwise.
475/// `quantized` carries the sub-click `residual` so a caller that needs it doesn't have
476/// to re-derive the click math a second time; `None` on the angular arm, where nothing
477/// is quantized.
478#[derive(Debug, Clone, Copy, PartialEq)]
479pub struct AdjustmentDisplay {
480    pub value: f64,
481    pub quantized: Option<Quantized>,
482}
483
484/// The one boundary where zero-set bias, tracking CF, and click quantization compose —
485/// the order is load-bearing (see main.rs's `zero_set_bias_applies_before_the_cf_division`
486/// and `clicks_arm_order_of_operations_is_load_bearing` tests) and must not move.
487pub fn adjustment_display(
488    drop_yd: f64,
489    range_yd: f64,
490    unit: AdjustmentUnit,
491    click: Option<ClickValue>,
492    bias_mil: f64,
493    cf: f64,
494) -> AdjustmentDisplay {
495    match click {
496        // Clicks convert from RAW drop (not from the angular value), so the CF is
497        // applied to the input drop — correcting the angle before click quantization.
498        // The zero-set bias joins as its drop-equivalent at this range (1 mil =
499        // range/1000), keeping the ordering: (true + bias) first, / CF second, then
500        // quantize into whole clicks.
501        Some(c) => {
502            let biased_drop_yd = if bias_mil != 0.0 {
503                drop_yd + bias_mil / 1000.0 * range_yd
504            } else {
505                drop_yd
506            };
507            let drop_for_clicks = biased_drop_yd / cf;
508            // Inlined from `clicks_for` (guard, then angle formula) so this boundary can
509            // quantize once and keep the residual, instead of calling `clicks_for` and
510            // throwing the sub-click remainder away.
511            if range_yd < 1.0 {
512                AdjustmentDisplay {
513                    value: 0.0,
514                    quantized: Some(Quantized {
515                        clicks: 0,
516                        residual: 0.0,
517                    }),
518                }
519            } else {
520                let angle = (drop_for_clicks / range_yd) * adjustment_factor(c.base);
521                let q = quantize_angle(angle, &c);
522                AdjustmentDisplay {
523                    value: q.clicks as f64,
524                    quantized: Some(q),
525                }
526            }
527        }
528        None => {
529            let true_need = drop_to_adjustment(drop_yd, range_yd, unit);
530            let biased = if bias_mil != 0.0 {
531                // Rescale the mil bias into the display unit via the shared factor
532                // table (MIL -> unit), so MOA/SMOA/IPHY columns get the same angular
533                // correction the MIL column does.
534                true_need
535                    + bias_mil * unit_factor_for_bias(unit) / adjustment_factor(ClickBase::Mil)
536            } else {
537                true_need
538            };
539            AdjustmentDisplay {
540                value: biased / cf,
541                quantized: None,
542            }
543        }
544    }
545}
546
547/// Windage-axis adjustment display (Tier 2 review C1 fix): only pass the click
548/// graduation through to `adjustment_display` when the windage axis itself is `Clicks`;
549/// otherwise this collapses to the angular path. All windage columns (range-table,
550/// wind-card, compare, the PDF dope card's wind_adj/lead_adj, and the bridge card
551/// service) go through this one function so the guard cannot be dropped again.
552pub fn windage_adjustment_display(
553    drift_yd: f64,
554    range_yd: f64,
555    windage_unit: AdjustmentUnit,
556    windage_click: Option<ClickValue>,
557    windage_bias_mil: f64,
558    windage_cf: f64,
559) -> AdjustmentDisplay {
560    let click = if windage_unit == AdjustmentUnit::Clicks {
561        windage_click
562    } else {
563        None
564    };
565    adjustment_display(
566        drift_yd,
567        range_yd,
568        windage_unit,
569        click,
570        windage_bias_mil,
571        windage_cf,
572    )
573}
574
575/// Display label for an [`AdjustmentUnit`] header/column name (MBA-1355/MBA-1410):
576/// every surface that prints one shares this, so the "MIL"/"MOA"/"SMOA"/"IPHY"/"CLICKS"
577/// spelling cannot drift between commands.
578pub fn adjustment_unit_label(unit: AdjustmentUnit) -> String {
579    match unit {
580        AdjustmentUnit::Mil => "MIL".to_string(),
581        AdjustmentUnit::Moa => "MOA".to_string(),
582        AdjustmentUnit::Smoa => "SMOA".to_string(),
583        AdjustmentUnit::Iphy => "IPHY".to_string(),
584        AdjustmentUnit::Clicks => "CLICKS".to_string(),
585    }
586}