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)]
183#[allow(clippy::items_after_test_module)] // Keep the adjustment display layer below in place.
184mod tests {
185    use super::*;
186
187    // MBA-1358: the WASM zero-banner emit rule, host-tested here because wasm.rs is
188    // wasm32-gated (the drag_coefficient_json_value pattern).
189    #[test]
190    fn zero_banner_dial_values_divide_by_the_elevation_cf_and_are_exact_at_one() {
191        let deg = 0.0717_f64;
192        let rad = deg.to_radians();
193        // cf = 1.0 is bit-exact against the historical inline expressions.
194        let (moa, mrad) = zero_banner_dial_values(deg, rad, 1.0);
195        assert_eq!(moa.to_bits(), (deg * 60.0).to_bits());
196        assert_eq!(mrad.to_bits(), (rad * 1000.0).to_bits());
197        // an under-tracking scope (CF < 1) needs MORE dial: outputs divide by the CF.
198        let (moa_cf, mrad_cf) = zero_banner_dial_values(deg, rad, 0.95);
199        assert_eq!(moa_cf.to_bits(), (deg * 60.0 / 0.95).to_bits());
200        assert_eq!(mrad_cf.to_bits(), (rad * 1000.0 / 0.95).to_bits());
201        assert!(moa_cf > moa && mrad_cf > mrad);
202    }
203
204    #[test]
205    fn parse_click_value_accepts_suffixed_and_rejects_bare() {
206        let c = parse_click_value("0.25moa").unwrap();
207        assert!((c.size - 0.25).abs() < 1e-12);
208        assert!(matches!(c.base, ClickBase::Moa));
209        assert!(matches!(
210            parse_click_value("0.1mil").unwrap().base,
211            ClickBase::Mil
212        ));
213        assert!(matches!(
214            parse_click_value("0.125smoa").unwrap().base,
215            ClickBase::Smoa
216        ));
217        assert!(matches!(
218            parse_click_value("0.125iphy").unwrap().base,
219            ClickBase::Smoa
220        ));
221        for bad in ["0.25", "moa", "-0.1mil", "0mil", "0.1mils", ""] {
222            assert!(parse_click_value(bad).is_err(), "{bad:?} must be rejected");
223        }
224        // error message names the accepted suffixes
225        let e = parse_click_value("0.25").unwrap_err();
226        assert!(e.contains("mil") && e.contains("moa"), "{e}");
227    }
228
229    #[test]
230    fn clicks_round_to_nearest_whole_click() {
231        let quarter_moa = parse_click_value("0.25moa").unwrap();
232        // 2.6 TMOA of drop -> 10.4 clicks -> 10
233        let drop_yd = 2.6 / 3438.0 * 100.0;
234        assert_eq!(clicks_for(drop_yd, 100.0, &quarter_moa), 10);
235        // negative (wind left) rounds symmetrically
236        assert_eq!(clicks_for(-drop_yd, 100.0, &quarter_moa), -10);
237        // 10.5 clicks rounds away from zero
238        let drop_yd = 2.625 / 3438.0 * 100.0;
239        assert_eq!(clicks_for(drop_yd, 100.0, &quarter_moa), 11);
240    }
241
242    #[test]
243    fn quantize_reconstruction_identity_is_bit_exact() {
244        // residual is DEFINED as angle - clicks*size, so reconstruction is exact by construction.
245        let c = ClickValue {
246            size: 0.25,
247            base: ClickBase::Moa,
248        };
249        for angle in [
250            0.0,
251            0.24,
252            0.25,
253            0.26,
254            -std::f64::consts::PI,
255            7.4499999,
256            1234.567,
257        ] {
258            let q = quantize_angle(angle, &c);
259            assert_eq!(
260                q.clicks as f64 * c.size + q.residual,
261                angle,
262                "identity broke at {angle}"
263            );
264            assert!(
265                q.residual.abs() <= c.size / 2.0 + f64::EPSILON,
266                "residual beyond half-click"
267            );
268        }
269    }
270
271    #[test]
272    fn quantize_exact_multiples_have_residual_exactly_zero() {
273        let c = ClickValue {
274            size: 0.1,
275            base: ClickBase::Mil,
276        };
277        let q = quantize_angle(0.1 * 27.0, &c);
278        assert_eq!(q.clicks, 27);
279        assert_eq!(q.residual, 0.0); // bit-exact, not approx
280    }
281
282    #[test]
283    fn quantize_ties_round_away_from_zero_matching_clicks_for() {
284        let c = ClickValue {
285            size: 0.5,
286            base: ClickBase::Mil,
287        };
288        assert_eq!(quantize_angle(0.25, &c).clicks, 1); // f64::round: ties away from zero
289        assert_eq!(quantize_angle(-0.25, &c).clicks, -1);
290    }
291
292    #[test]
293    fn clicks_for_is_unchanged_and_delegates() {
294        // Historical behavior pinned: sub-1yd guard + rounding. Values chosen off any tie.
295        let c = ClickValue {
296            size: 0.25,
297            base: ClickBase::Moa,
298        };
299        assert_eq!(clicks_for(10.0, 0.5, &c), 0); // range_yd < 1.0 guard
300        let angle = (7.2 / 300.0) * adjustment_factor(ClickBase::Moa);
301        assert_eq!(
302            clicks_for(7.2, 300.0, &c),
303            (angle / 0.25_f64).round() as i64
304        );
305    }
306
307    #[test]
308    fn click_size_mil_converts_via_the_locked_factor_table() {
309        let moa = ClickValue {
310            size: 0.25,
311            base: ClickBase::Moa,
312        };
313        // 0.25 MOA in mil via the LOCKED 3438 constant (MBA-724), not 3437.7467.
314        assert_eq!(click_size_mil(&moa), 0.25 * 1000.0 / 3438.0);
315        let mil = ClickValue {
316            size: 0.1,
317            base: ClickBase::Mil,
318        };
319        assert_eq!(click_size_mil(&mil), 0.1);
320    }
321
322    // MBA-1348: ClickValue's JSON wire form is the same suffixed string
323    // `parse_click_value` already parses, not a structural `{size, base}` object.
324    #[test]
325    fn click_value_json_is_pinned_to_the_suffixed_string() {
326        let quarter_moa = ClickValue {
327            size: 0.25,
328            base: ClickBase::Moa,
329        };
330        assert_eq!(serde_json::to_string(&quarter_moa).unwrap(), "\"0.25moa\"");
331
332        let tenth_mil = ClickValue {
333            size: 0.1,
334            base: ClickBase::Mil,
335        };
336        assert_eq!(serde_json::to_string(&tenth_mil).unwrap(), "\"0.1mil\"");
337
338        // A whole-number size prints without a trailing ".0" -- `f64`'s shortest Display.
339        let one_smoa = ClickValue {
340            size: 1.0,
341            base: ClickBase::Smoa,
342        };
343        assert_eq!(serde_json::to_string(&one_smoa).unwrap(), "\"1smoa\"");
344    }
345
346    #[test]
347    fn click_value_round_trips_through_json_including_smoa() {
348        let cases = [
349            ClickValue {
350                size: 0.1,
351                base: ClickBase::Mil,
352            },
353            ClickValue {
354                size: 0.25,
355                base: ClickBase::Moa,
356            },
357            ClickValue {
358                size: 1.0,
359                base: ClickBase::Smoa,
360            },
361            ClickValue {
362                size: 0.125,
363                base: ClickBase::Smoa,
364            },
365        ];
366        for click in cases {
367            let json = serde_json::to_string(&click).unwrap();
368            let parsed: ClickValue = serde_json::from_str(&json).unwrap();
369            assert_eq!(parsed, click, "round trip broke for {json}");
370        }
371    }
372
373    #[test]
374    fn click_value_deserialize_rejects_what_parse_click_value_rejects() {
375        // The Deserialize impl IS parse_click_value -- a non-positive or malformed
376        // string must fail to deserialize exactly as it fails to parse.
377        for bad in ["\"0mil\"", "\"-0.1mil\"", "\"0.25\"", "\"nonsense\""] {
378            assert!(
379                serde_json::from_str::<ClickValue>(bad).is_err(),
380                "{bad} must be rejected"
381            );
382        }
383    }
384}
385
386// ---------------------------------------------------------------------------
387// Adjustment display layer (moved from main.rs for the bridge card service).
388//
389// Everything below was previously private to the CLI. The bridge's card
390// commands need the exact same unit conversion, zero-set bias, tracking-CF,
391// and click-quantization ordering the CLI prints, so the shared boundary now
392// lives here and `main.rs` re-imports it. The module doc's historical note
393// about avoiding a circular dependency on main.rs's AdjustmentUnit no longer
394// applies: the enum itself moved here, and the CLI derives its clap face via
395// cfg_attr.
396// ---------------------------------------------------------------------------
397
398/// Printed adjustment unit for dial columns (MIL/MOA/SMOA/IPHY or whole clicks).
399///
400/// Every variant also deserializes from the UPPERCASE spelling [`adjustment_unit_label`]
401/// prints for it (MBA-1519). A card response's `units.elevation_adjustment` and
402/// `units.windage_adjustment` read `"MIL"`/`"MOA"`/`"SMOA"`/`"IPHY"`/`"CLICKS"`, and a caller
403/// that writes a card's own units block back into the next request is doing the obvious
404/// thing; before these aliases those two fields were the only ones in that block that came
405/// back as ``unknown variant `MIL` ``. Accepting a second spelling is additive on every
406/// surface that deserializes this enum (the card request's `adjustment_unit`/`windage_unit`
407/// and truing's `unit`), and what the enum PRINTS is unchanged — `Serialize` still emits the
408/// lowercase name, so no stored document or response byte moves.
409#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(rename_all = "lowercase")]
411#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
412pub enum AdjustmentUnit {
413    /// Milliradians (1 MIL = 3.6 inches at 100 yards)
414    #[default]
415    #[serde(alias = "MIL")]
416    Mil,
417    /// Minutes of Angle, true MOA (1 MOA = 1.047 inches at 100 yards)
418    #[serde(alias = "MOA")]
419    Moa,
420    /// Shooter's MOA (exactly 1 inch per 100 yards)
421    #[serde(alias = "SMOA")]
422    Smoa,
423    /// Inches per hundred yards (numerically identical to SMOA)
424    #[serde(alias = "IPHY")]
425    Iphy,
426    /// Whole turret clicks; requires an elevation click graduation
427    /// (--elevation-click-value or the profile's elevation_click)
428    #[serde(alias = "CLICKS")]
429    Clicks,
430}
431
432/// Convert drop to adjustment unit (MIL, MOA, or SMOA/IPHY). `unit` maps onto
433/// `ClickBase`/`adjustment_factor` (MBA-1355) so the CLI, the WASM terminal, and the
434/// bridge card service share one conversion table.
435///
436/// `AdjustmentUnit::Clicks` has no fixed factor of its own — clicks depend on a graduation
437/// (`ClickValue`) supplied separately — so callers MUST resolve clicks via `clicks_for()`
438/// before ever reaching this function with `unit == Clicks`. That arm exists only as a
439/// release-safe fallback (falls back to MIL) guarded by a `debug_assert!`.
440pub fn drop_to_adjustment(drop_yd: f64, range_yd: f64, unit: AdjustmentUnit) -> f64 {
441    if range_yd < 1.0 {
442        return 0.0;
443    }
444    let factor = match unit {
445        AdjustmentUnit::Mil => adjustment_factor(ClickBase::Mil),
446        AdjustmentUnit::Moa => adjustment_factor(ClickBase::Moa),
447        AdjustmentUnit::Smoa | AdjustmentUnit::Iphy => adjustment_factor(ClickBase::Smoa),
448        AdjustmentUnit::Clicks => {
449            // Callers resolve clicks via clicks_for() BEFORE display; reaching this
450            // arm is a scoping bug. Fall back to MIL so release builds stay sane.
451            debug_assert!(false, "Clicks must be resolved via clicks_for()");
452            adjustment_factor(ClickBase::Mil)
453        }
454    };
455    (drop_yd / range_yd) * factor
456}
457
458/// The `adjustment_factor` a non-clicks [`AdjustmentUnit`] renders in, for rescaling a
459/// MIL-denominated zero-set bias into the active display unit (MBA-1360). `Clicks`
460/// never reaches this (the clicks arm below works on raw drop); the MIL fallback
461/// mirrors `drop_to_adjustment`'s own release-safe arm.
462pub fn unit_factor_for_bias(unit: AdjustmentUnit) -> f64 {
463    match unit {
464        AdjustmentUnit::Mil => adjustment_factor(ClickBase::Mil),
465        AdjustmentUnit::Moa => adjustment_factor(ClickBase::Moa),
466        AdjustmentUnit::Smoa | AdjustmentUnit::Iphy => adjustment_factor(ClickBase::Smoa),
467        AdjustmentUnit::Clicks => {
468            debug_assert!(false, "Clicks bias is applied on raw drop, not here");
469            adjustment_factor(ClickBase::Mil)
470        }
471    }
472}
473
474/// An adjustment value plus the click-quantization detail (Plan B Task 2). `value` is
475/// the whole-click count as an `f64` on the clicks arm, the plain angle otherwise.
476/// `quantized` carries the sub-click `residual` so a caller that needs it doesn't have
477/// to re-derive the click math a second time; `None` on the angular arm, where nothing
478/// is quantized.
479#[derive(Debug, Clone, Copy, PartialEq)]
480pub struct AdjustmentDisplay {
481    pub value: f64,
482    pub quantized: Option<Quantized>,
483}
484
485/// The one boundary where zero-set bias, tracking CF, and click quantization compose —
486/// the order is load-bearing (see main.rs's `zero_set_bias_applies_before_the_cf_division`
487/// and `clicks_arm_order_of_operations_is_load_bearing` tests) and must not move.
488pub fn adjustment_display(
489    drop_yd: f64,
490    range_yd: f64,
491    unit: AdjustmentUnit,
492    click: Option<ClickValue>,
493    bias_mil: f64,
494    cf: f64,
495) -> AdjustmentDisplay {
496    match click {
497        // Clicks convert from RAW drop (not from the angular value), so the CF is
498        // applied to the input drop — correcting the angle before click quantization.
499        // The zero-set bias joins as its drop-equivalent at this range (1 mil =
500        // range/1000), keeping the ordering: (true + bias) first, / CF second, then
501        // quantize into whole clicks.
502        Some(c) => {
503            let biased_drop_yd = if bias_mil != 0.0 {
504                drop_yd + bias_mil / 1000.0 * range_yd
505            } else {
506                drop_yd
507            };
508            let drop_for_clicks = biased_drop_yd / cf;
509            // Inlined from `clicks_for` (guard, then angle formula) so this boundary can
510            // quantize once and keep the residual, instead of calling `clicks_for` and
511            // throwing the sub-click remainder away.
512            if range_yd < 1.0 {
513                AdjustmentDisplay {
514                    value: 0.0,
515                    quantized: Some(Quantized {
516                        clicks: 0,
517                        residual: 0.0,
518                    }),
519                }
520            } else {
521                let angle = (drop_for_clicks / range_yd) * adjustment_factor(c.base);
522                let q = quantize_angle(angle, &c);
523                AdjustmentDisplay {
524                    value: q.clicks as f64,
525                    quantized: Some(q),
526                }
527            }
528        }
529        None => {
530            let true_need = drop_to_adjustment(drop_yd, range_yd, unit);
531            let biased = if bias_mil != 0.0 {
532                // Rescale the mil bias into the display unit via the shared factor
533                // table (MIL -> unit), so MOA/SMOA/IPHY columns get the same angular
534                // correction the MIL column does.
535                true_need
536                    + bias_mil * unit_factor_for_bias(unit) / adjustment_factor(ClickBase::Mil)
537            } else {
538                true_need
539            };
540            AdjustmentDisplay {
541                value: biased / cf,
542                quantized: None,
543            }
544        }
545    }
546}
547
548/// Windage-axis adjustment display (Tier 2 review C1 fix): only pass the click
549/// graduation through to `adjustment_display` when the windage axis itself is `Clicks`;
550/// otherwise this collapses to the angular path. All windage columns (range-table,
551/// wind-card, compare, the PDF dope card's wind_adj/lead_adj, and the bridge card
552/// service) go through this one function so the guard cannot be dropped again.
553pub fn windage_adjustment_display(
554    drift_yd: f64,
555    range_yd: f64,
556    windage_unit: AdjustmentUnit,
557    windage_click: Option<ClickValue>,
558    windage_bias_mil: f64,
559    windage_cf: f64,
560) -> AdjustmentDisplay {
561    let click = if windage_unit == AdjustmentUnit::Clicks {
562        windage_click
563    } else {
564        None
565    };
566    adjustment_display(
567        drift_yd,
568        range_yd,
569        windage_unit,
570        click,
571        windage_bias_mil,
572        windage_cf,
573    )
574}
575
576/// Display label for an [`AdjustmentUnit`] header/column name (MBA-1355/MBA-1410):
577/// every surface that prints one shares this, so the "MIL"/"MOA"/"SMOA"/"IPHY"/"CLICKS"
578/// spelling cannot drift between commands.
579pub fn adjustment_unit_label(unit: AdjustmentUnit) -> String {
580    match unit {
581        AdjustmentUnit::Mil => "MIL".to_string(),
582        AdjustmentUnit::Moa => "MOA".to_string(),
583        AdjustmentUnit::Smoa => "SMOA".to_string(),
584        AdjustmentUnit::Iphy => "IPHY".to_string(),
585        AdjustmentUnit::Clicks => "CLICKS".to_string(),
586    }
587}