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}