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}