Skip to main content

ballistics_engine/
card_service.rs

1//! Transport-free card-generation services (come-ups, range table, wind card).
2//!
3//! These replicate the CLI's card commands exactly — same zero solve, same sampled
4//! trajectory, same nearest-sample row selection, same adjustment/bias/CF/click
5//! ordering via [`crate::adjustment`] — as versioned request/response types the
6//! bridge can expose to embedded consumers. The CLI remains the reference
7//! implementation; `tests/card_bridge_golden.rs` asserts row-for-row agreement.
8//!
9//! ## Unit convention
10//!
11//! Unlike solve-json (explicit SI), card requests are denominated in the shooter's own
12//! units, exactly like the CLI flags they mirror: imperial = fps, grains, inches, yards,
13//! °F, inHg, mph; metric = m/s, grams, mm, meters, °C, hPa, m/s. A DOPE card is a display
14//! artifact; its inputs and outputs share the shooter's unit world, and this keeps the
15//! request shape identical to the documented CLI surface.
16//!
17//! ### Per-dimension units (MBA-1519)
18//!
19//! Nobody shoots in one system. A Finnish shooter ranges in metres, dials MIL, and buys
20//! bullets labelled in grains, because that is how the industry sells them; European makers
21//! dual-print `grams / grains` rather than replacing one with the other, and mix within a
22//! single table. So the denominating state on this request is a unit PER DIMENSION, and
23//! [`CardUnits`] is a one-shot preset that fills in every dimension the request does not
24//! state for itself — not a mode the card is "in".
25//!
26//! Concretely: an explicit per-dimension field wins; an absent one falls back to what
27//! `units` implies. A request written before those fields existed states none of them, so
28//! every dimension falls back and the card is byte-for-byte the card it always was
29//! (`tests/card_units_per_dimension.rs` pins that against a stored document).
30//!
31//! The response needed no new FIELD for any of this: [`CardUnitsBlockV1`] has always carried
32//! seven independent label fields, so a card whose distance is metres and whose velocity is
33//! fps already had somewhere to say so. It did need the two angular labels to become
34//! requestable: `elevation_adjustment`/`windage_adjustment` print `"MIL"`/`"MOA"`/`"SMOA"`/
35//! `"IPHY"`/`"CLICKS"` and [`AdjustmentUnit`] deserialized lowercase only, so those two were
36//! the only fields of the block a caller could not echo back. They take the uppercase
37//! spellings as aliases now, which makes "echo the units block into the next request" true
38//! of all seven fields rather than five of them.
39//!
40//! Three candidates do NOT get a new field, and each for its own reason:
41//!
42//! * **Scope adjustment** is already per-dimension, and was first: `adjustment_unit` and
43//!   `windage_unit` are separate fields with separate click graduations, because a shooter
44//!   with a MIL rifle and an MOA rifle has two scopes rather than a preference. SMOA (and
45//!   its `iphy` spelling) is already one of the values they take.
46//! * **Clicks** is not a units dimension. A click is meaningless without a graduation, and
47//!   this module already enforces that: `adjustment_unit == Clicks` without an
48//!   `elevation_click_value` is a refusal. The graduation belongs beside the turret's
49//!   MIL/MOA/SMOA marking, not in a units picker.
50//! * **Twist rate** is a dimension of the design but not of this request — a card request
51//!   carries no twist, so there is nothing here for a twist unit to denominate.
52//!
53//! And the linear-at-distance spellings are NOT units: "inches@100yd" IS SMOA — the same
54//! 1/3600 rad, the same printed number, no conversion — and "cm/100m" is 0.1 mrad, i.e. MIL
55//! with a factor-of-ten display. They are alternate HEADER TEXT for units this request
56//! already has, and adding them as values would give a caller two spellings that produce
57//! identical numbers.
58//!
59//! ### What the response does NOT tell a caller
60//!
61//! Like every other default on this request — `sight_height`, `temperature`, `pressure`,
62//! `humidity`, `windage_unit` — a per-dimension unit taken from the preset is applied
63//! silently, and this surface has no notice channel at all (there is no `assumptions` array
64//! on [`CardResponseV1`], unlike solve-json's `SolveSuccessV1`).
65//!
66//! Be precise about what that costs, because a paragraph of prose can argue it away. The `units`
67//! block names the unit of the SEVEN OUTPUT COLUMNS and nothing else. It says nothing about
68//! the six dimensions that are input-only — `mass`, `diameter`, `sight_height`,
69//! `temperature`, `pressure`, `altitude` — so for those a caller cannot read back off the
70//! card which unit the engine settled on.
71//!
72//! `altitude` is the sharp one, because it is the dimension whose fallback is deliberately
73//! NOT the preset's: an imperial `card.range_table` carrying `altitude: 5000` and no
74//! `altitude_unit` — which a US caller writing an imperial card means as feet — is solved at
75//! 5000 METRES, and no field of the response says so. On a .308 175 gr that is ~1.9 MIL of
76//! elevation at 1000 yd against the same card with `altitude_unit: "feet"`. The BEHAVIOUR
77//! predates the per-dimension fields (`altitude` has always reached
78//! [`AtmosphericConditions`] unconverted); what is new is that the request can now name the
79//! dimension, so the gap is describable instead of merely present.
80//!
81//! No notice was added here, and the reason is narrow rather than principled: any notice —
82//! an `assumptions` array, or extra label fields on the units block — is a change to the
83//! RESPONSE bytes of every card, including the stored documents the reprint path compares
84//! label-for-label and the six goldens that are the whole evidence for this change being
85//! additive on the wire. A response-schema change belongs to a ticket that can move those
86//! goldens honestly. Until then the remedy is on the request: a caller that cares which
87//! altitude it got states `altitude_unit`, and a card built by an app should state every
88//! dimension rather than lean on a preset.
89
90use serde::{Deserialize, Serialize};
91
92use crate::adjustment::{
93    adjustment_display, adjustment_unit_label, parse_click_value, windage_adjustment_display,
94    AdjustmentUnit, ClickValue,
95};
96use crate::atmosphere::StationAtmospherePresence;
97use crate::hold_curve::{
98    card_sample_max_range_m, range_not_sampled_message, run_sampled_flight_with_station_presence,
99    sample_at_range, CardTruncation, SampledFlight, CARD_SAMPLE_INTERVAL_M,
100};
101use crate::{AtmosphericConditions, BCSegmentData, BallisticInputs, DragModel, WindConditions};
102
103/// Schema version of the card request/response contract.
104pub const CARD_SCHEMA_VERSION_V1: u32 = 1;
105
106const GRAINS_TO_KG: f64 = crate::constants::GRAINS_TO_KG;
107
108/// The unit PRESET for a card request: a one-shot bulk action that fills in every
109/// per-dimension unit the request does not state for itself, not a mode the card is in.
110///
111/// It stays because it is the whole of the pre-MBA-1519 contract and because "give me an
112/// imperial card" remains the overwhelmingly common request. What changed is its standing:
113/// it is now the FALLBACK for each dimension rather than the denominator of all of them.
114#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "lowercase")]
116pub enum CardUnits {
117    #[default]
118    Imperial,
119    Metric,
120}
121
122// --- Per-dimension units (MBA-1519) ------------------------------------------------------
123//
124// One enum per dimension the card request actually denominates. Each is a plain value list;
125// the mapping from a value to a conversion factor and to a printed label lives in exactly
126// one place, `Units` below, so a dimension cannot convert one way and be labelled another.
127//
128// Every enum carries serde aliases for the spelling the RESPONSE prints for that unit
129// (`"m/s"`, `"ft-lb"`, `"in"`, `"yd"`, ...), and so does [`AdjustmentUnit`] over in
130// `adjustment.rs` for the two uppercase dial labels. A caller that reads a card's `units`
131// block and writes those strings straight back into the next request is doing the obvious
132// thing, and it now works — for all seven fields of the block — instead of failing on a
133// request that denies unknown values.
134
135/// Range axis: what `zero_distance`, `start`/`end`/`step` and every row's `range` are in.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "lowercase")]
138pub enum CardDistanceUnit {
139    #[serde(alias = "yd", alias = "yard")]
140    Yards,
141    #[serde(alias = "m", alias = "metres", alias = "meter", alias = "metre")]
142    Meters,
143}
144
145/// Speed of the bullet: `muzzle_velocity`, `bc_segments` band edges, the `velocity` column.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(rename_all = "lowercase")]
148pub enum CardVelocityUnit {
149    #[serde(alias = "ft/s")]
150    Fps,
151    #[serde(alias = "m/s")]
152    Mps,
153}
154
155/// Bullet weight. Grains and grams differ by 15.43x, and on the BC path mass never enters
156/// the drag denominator — it touches energy and stability only — so a grams-for-grains slip
157/// yields a trajectory that looks entirely normal. Which is exactly why the unit has to be
158/// stated per-dimension rather than inferred: the dual-printed `g / gr` box a European
159/// shooter is holding is the normal case, not an edge one.
160///
161/// The one-character wire tokens `"g"` and `"gr"` are deliberately NOT accepted, though
162/// `"gram"`/`"grams"` and `"grain"`/`"grains"` are. Those two tokens are the slip this
163/// dimension exists to prevent: a 175-grain load sent as `"g"` solves to bit-identical
164/// dials, drift, velocity and time — only the energy column moves, 1009 ft-lb to 15575 — and
165/// the response has no mass label for a reader to catch it on. Nor can a plausibility guard
166/// catch it: bullets run roughly 5–800 grains and roughly 0.3–52 grams, so the two ranges
167/// OVERLAP across 5–52, where 48 grains (a .22 varmint bullet) and 48 grams (a .50 BMG) are
168/// both ordinary. There is no threshold to put a guard at, so the defence has to be the
169/// spelling, and the spelled-out name is the one thing a caller cannot typo into the other
170/// unit.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all = "lowercase")]
173pub enum CardMassUnit {
174    #[serde(alias = "grain")]
175    Grains,
176    #[serde(alias = "gram")]
177    Grams,
178}
179
180/// A small linear length. Shared by the request's two TARGET-SIDE linear dimensions
181/// (sight-to-bore geometry and the linear drop column) because they take the same values,
182/// never because they are the same setting: they are separate fields and resolve
183/// independently.
184///
185/// Bullet diameter is NOT one of them — it has [`CardDiameterUnit`], a narrower vocabulary.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(rename_all = "lowercase")]
188pub enum CardLinearUnit {
189    #[serde(alias = "in", alias = "inch")]
190    Inches,
191    #[serde(alias = "centimeters", alias = "centimetres")]
192    Cm,
193    #[serde(alias = "millimeters", alias = "millimetres")]
194    Mm,
195}
196
197/// Bullet diameter: inches or millimetres, the two units a calibre is ever quoted in.
198///
199/// Its own enum rather than a third user of [`CardLinearUnit`], which would also accept
200/// `cm`. The design's dimension table lists inches and mm for this dimension and no third
201/// value, and a wire vocabulary on a `deny_unknown_fields` paid contract is far cheaper to
202/// widen later than to narrow: nobody quotes a calibre in centimetres, so `0.782` cm is
203/// likelier to be a slipped `7.82` mm than a request anyone meant.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "lowercase")]
206pub enum CardDiameterUnit {
207    #[serde(alias = "in", alias = "inch")]
208    Inches,
209    #[serde(alias = "millimeters", alias = "millimetres")]
210    Mm,
211}
212
213/// Wind and crossing-target speed. Four values, not two: a European card reads km/h and a
214/// coastal one reads knots, and neither is expressible by picking a system.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "lowercase")]
217pub enum CardWindSpeedUnit {
218    Mph,
219    #[serde(alias = "m/s")]
220    Mps,
221    #[serde(alias = "km/h", alias = "kmh")]
222    Kph,
223    #[serde(alias = "kn", alias = "kt", alias = "knot")]
224    Knots,
225}
226
227/// Air temperature.
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(rename_all = "lowercase")]
230pub enum CardTemperatureUnit {
231    #[serde(alias = "f", alias = "degf")]
232    Fahrenheit,
233    #[serde(alias = "c", alias = "degc")]
234    Celsius,
235}
236
237/// Station pressure. `hpa` and `mbar` are the same number, which is why mbar is an alias
238/// rather than a value of its own.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "lowercase")]
241pub enum CardPressureUnit {
242    #[serde(alias = "in-hg", alias = "inchesofmercury")]
243    InHg,
244    #[serde(alias = "mbar", alias = "millibar")]
245    HPa,
246}
247
248/// Terminal energy column.
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "lowercase")]
251pub enum CardEnergyUnit {
252    #[serde(alias = "ft-lb", alias = "ftlbs", alias = "footpounds")]
253    FtLb,
254    // "J" as well as "j": the response's energy label is the capitalized SI symbol, and the
255    // aliases exist precisely so a caller can echo a card's own labels back at it.
256    #[serde(alias = "j", alias = "J", alias = "joule")]
257    Joules,
258}
259
260/// Site elevation. See [`CardRequestV1::altitude_unit`] for why this one dimension's
261/// default does not come from the preset.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(rename_all = "lowercase")]
264pub enum CardAltitudeUnit {
265    #[serde(alias = "ft", alias = "foot")]
266    Feet,
267    #[serde(alias = "m", alias = "metres", alias = "meter", alias = "metre")]
268    Meters,
269}
270
271/// Everything the three card surfaces share: load, zero, atmosphere, display axes.
272///
273/// Field values are in this request's PER-DIMENSION units: each dimensional field names the
274/// `*_unit` field that denominates it, and each of those falls back to what `units` implies
275/// when it is absent (see the module docs). Stating none of them is the pre-MBA-1519 shape
276/// and reproduces the pre-MBA-1519 card exactly.
277///
278/// ## Adding a field here is additive on the WIRE and breaking in RUST
279///
280/// This is a deserialization target: the supported way to build one is
281/// `serde_json::from_str`/`from_value` on a JSON document, which is what the bridge and every
282/// binding do, and an optional new field costs such a caller nothing. A downstream Rust crate
283/// that constructs it with a struct literal has no such luck — every added field is an
284/// `E0063: missing fields` against code that compiled before, which is exactly what MBA-1519's
285/// eleven fields do to a crate written against 0.39.x. The remedy is one line per field
286/// (`altitude_unit: None`, and so on for the rest); the intended path is to deserialize.
287///
288/// `#[non_exhaustive]` was considered and NOT applied. It would break the same callers in the
289/// same release, and — with no `Default` and no builder on this type — it would leave a
290/// downstream crate no way to construct the request at all, trading one fixable compile error
291/// for a permanent one. So the breakage is stated in the CHANGELOG for the release that causes
292/// it rather than encoded in the type, and a future field will be stated the same way.
293#[derive(Debug, Clone, Deserialize)]
294#[serde(deny_unknown_fields)]
295pub struct CardRequestV1 {
296    /// The preset that fills in every `*_unit` field below that this request leaves absent.
297    #[serde(default)]
298    pub units: CardUnits,
299
300    // Load
301    /// Muzzle speed, in `velocity_unit`.
302    pub muzzle_velocity: f64,
303    pub ballistic_coefficient: f64,
304    /// Bullet weight, in `mass_unit`.
305    pub mass: f64,
306    /// Bullet diameter, in `diameter_unit`.
307    pub diameter: f64,
308    #[serde(default)]
309    pub drag_model: DragModelV1,
310    /// Sight height over bore, in `sight_height_unit`; CLI default 1.5 in.
311    #[serde(default = "sight_height_unset")]
312    pub sight_height: f64,
313
314    // Zero
315    /// Zero range, in `distance_unit`.
316    pub zero_distance: f64,
317    /// Deliberate POI offset at the zero range, in `drop_unit` — it is a displacement on
318    /// the target, which is the dimension the drop column reports; 0 = zeroed dead-on.
319    #[serde(default)]
320    pub zero_poi_vertical: f64,
321    /// Deliberate lateral POI offset at the zero range, in `drop_unit`.
322    #[serde(default)]
323    pub zero_poi_horizontal: f64,
324    /// Lateral sight-to-bore mount offset, in `sight_height_unit` — it is rifle geometry
325    /// measured beside the sight height, not a figure read off a target.
326    #[serde(default)]
327    pub sight_offset_lateral: f64,
328
329    // Atmosphere (CLI defaults: 59 °F / 15 °C, 29.92 inHg / 1013.25 hPa, 50 %, 0 alt)
330    /// Station temperature, in `temperature_unit`.
331    #[serde(default)]
332    pub temperature: Option<f64>,
333    /// Station pressure, in `pressure_unit`.
334    #[serde(default)]
335    pub pressure: Option<f64>,
336    /// Relative humidity, percent. Dimensionless, so no unit field governs it.
337    #[serde(default = "default_humidity")]
338    pub humidity: f64,
339    /// Site elevation, in `altitude_unit` — which defaults to METRES whatever `units` says.
340    #[serde(default)]
341    pub altitude: f64,
342
343    // Wind for the flight (in `wind_speed_unit`; wind-FROM degrees). The wind card ignores
344    // these and sweeps its own speed list.
345    #[serde(default)]
346    pub wind_speed: f64,
347    #[serde(default)]
348    pub wind_direction_deg: f64,
349
350    // Card domain, in `distance_unit`
351    pub start: f64,
352    pub end: f64,
353    pub step: f64,
354
355    // --- Per-dimension units (MBA-1519) --------------------------------------------------
356    //
357    // Every one of these is `Option`al and `#[serde(default)]`: `CardRequestV1` denies
358    // unknown fields and backs paid surfaces (the Pro DOPE card, the saved-card reprint, the
359    // PDF export), so a stored request written before they existed has to keep parsing and
360    // keep producing the identical document. `None` means "take it from `units`", which is
361    // what every such request says about every dimension.
362    //
363    // What is deliberately NOT here: a scope-adjustment unit (`adjustment_unit` /
364    // `windage_unit` below already are that dimension, SMOA included), a clicks unit (a
365    // click needs a graduation, which is `elevation_click_value`), a twist-rate unit (this
366    // request carries no twist), and the linear-at-distance spellings (header text for SMOA
367    // and MIL, not units). The module docs give the full reasoning.
368    /// Unit of `zero_distance`, `start`, `end`, `step` and every row's `range`.
369    /// Default: yards (imperial) / metres (metric).
370    ///
371    /// Distance is the dimension where reinterpreting the number does the most damage the
372    /// most quietly: "300" yards and "300" metres are targets 9.36% apart, and a stored zero,
373    /// a DOPE row and a target-size hold are all attached to a PHYSICAL distance rather than
374    /// to a unit-free label. So a card's distance unit is stated, never inferred from a
375    /// neighbouring dimension.
376    #[serde(default)]
377    pub distance_unit: Option<CardDistanceUnit>,
378    /// Unit of `muzzle_velocity`, the `bc_segments` band edges and the `velocity` column.
379    /// Default: fps (imperial) / m/s (metric).
380    #[serde(default)]
381    pub velocity_unit: Option<CardVelocityUnit>,
382    /// Unit of `mass`. Default: grains (imperial) / grams (metric).
383    #[serde(default)]
384    pub mass_unit: Option<CardMassUnit>,
385    /// Unit of `diameter`. Default: inches (imperial) / mm (metric). Those two values and no
386    /// others — see [`CardDiameterUnit`].
387    #[serde(default)]
388    pub diameter_unit: Option<CardDiameterUnit>,
389    /// Unit of `sight_height` and `sight_offset_lateral` — the sight-to-bore geometry.
390    /// Default: inches (imperial) / mm (metric).
391    ///
392    /// This is a dimension the design's table does not contain, added deliberately rather
393    /// than by oversight: without it, the only home for the sight height would be
394    /// `drop_unit`, and then asking for a drop column in centimetres would silently reread a
395    /// 1.5-inch sight height as 1.5 cm. It is a twelfth field on a `deny_unknown_fields`
396    /// contract that cannot be withdrawn without a breaking change, which is the cost being
397    /// accepted here.
398    ///
399    /// Separate from `drop_unit` on purpose. They are the same unit today only because both
400    /// presets happen to give them the same value; they are different measurements (one is
401    /// taken on the rifle with calipers, the other is read off a target downrange), and
402    /// folding them together would mean a caller who asks for a drop column in centimetres
403    /// silently reinterprets a 1.5-inch sight height as 1.5 cm.
404    #[serde(default)]
405    pub sight_height_unit: Option<CardLinearUnit>,
406    /// Unit of the `drop_linear` / `wind_linear` columns and of `zero_poi_vertical` /
407    /// `zero_poi_horizontal`. Default: inches (imperial) / mm (metric).
408    ///
409    /// The ANGULAR half of the design's drop dimension is `adjustment_unit` /
410    /// `windage_unit`, which have always been separate fields; this is the linear half, and
411    /// `cm` is available here because a metric card prints 250 cm of drop, not 2500 mm.
412    #[serde(default)]
413    pub drop_unit: Option<CardLinearUnit>,
414    /// Unit of `wind_speed`, of every entry in `wind_speeds`, and of `pdf.target_speed`.
415    /// Default: mph (imperial) / m/s (metric).
416    #[serde(default)]
417    pub wind_speed_unit: Option<CardWindSpeedUnit>,
418    /// Unit of `temperature` and of the temperature default. Default: °F (imperial) / °C
419    /// (metric).
420    #[serde(default)]
421    pub temperature_unit: Option<CardTemperatureUnit>,
422    /// Unit of `pressure` and of the pressure default. Default: inHg (imperial) / hPa
423    /// (metric).
424    #[serde(default)]
425    pub pressure_unit: Option<CardPressureUnit>,
426    /// Unit of the `energy` column. Default: ft-lb (imperial) / J (metric).
427    #[serde(default)]
428    pub energy_unit: Option<CardEnergyUnit>,
429    /// Unit of `altitude`. Default: **metres, whatever `units` says** — this is the one
430    /// dimension whose fallback is not the preset's.
431    ///
432    /// `altitude` has ignored the `units` scalar since this module was written: it is passed
433    /// to [`AtmosphericConditions`] unconverted, and that field is documented metres, so an
434    /// imperial card's altitude has always been metres too (the PDF header divides it by
435    /// 0.3048 regardless of `units` for exactly that reason). Letting the imperial preset
436    /// start filling this with `feet` would therefore not be additive: every stored imperial
437    /// card carrying `altitude: 1000` would silently become a card shot at 304.8 m instead
438    /// of 1000 m, which is a different density altitude and a different trajectory.
439    ///
440    /// So the preset does not reach this field. A caller that wants feet says `"feet"`, and
441    /// gets the first way this request has ever had to express one.
442    #[serde(default)]
443    pub altitude_unit: Option<CardAltitudeUnit>,
444
445    // Display axes
446    #[serde(default)]
447    pub adjustment_unit: AdjustmentUnit,
448    /// Windage axis unit; defaults to the elevation axis.
449    #[serde(default)]
450    pub windage_unit: Option<AdjustmentUnit>,
451    /// Turret graduations as suffixed strings ("0.1mil", "0.25moa"); required when
452    /// the corresponding axis unit is `clicks`. Windage falls back to elevation.
453    #[serde(default)]
454    pub elevation_click_value: Option<String>,
455    #[serde(default)]
456    pub windage_click_value: Option<String>,
457    /// Scope tracking correction factors (MBA-1358); 1.0 = tracks true.
458    #[serde(default = "default_cf")]
459    pub elevation_cf: f64,
460    #[serde(default = "default_cf")]
461    pub windage_cf: f64,
462    /// Selected zero set's dial corrections, true angular MIL (MBA-1360).
463    #[serde(default)]
464    pub zero_set_elevation_bias_mil: f64,
465    #[serde(default)]
466    pub zero_set_windage_bias_mil: f64,
467
468    /// Wind card only: the sweep of wind speeds, each in `wind_speed_unit` (so km/h and
469    /// knots are available here too), one output column each.
470    #[serde(default)]
471    pub wind_speeds: Vec<f64>,
472    /// Wind card only: wind-FROM angles in degrees; default `[90]` (full-value from
473    /// the right), matching the CLI.
474    #[serde(default)]
475    pub wind_angles_deg: Vec<f64>,
476
477    /// Explicit velocity-banded BC schedule, mirroring the CLI's repeatable
478    /// `--bc-segment VMIN:VMAX:BC`. Velocities are in the request's `velocity_unit`,
479    /// exactly like the CLI flag. When supplied it
480    /// wins over `bc5d_table_path`, and — CLI parity — the scalar
481    /// `ballistic_coefficient` remains the interior-gap fallback unchanged. The
482    /// schedule feeds BOTH the zero solve and every sampled trajectory.
483    ///
484    /// Bands may be listed in any order and may touch or leave gaps, but must not overlap; at
485    /// most [`crate::solve_json::MAX_BC_SEGMENTS_V1`] bands; each `bc` within 0.0001..=100, the
486    /// bound solve-json puts on a BC. `[]` and `null` mean no ladder (MBA-1600).
487    #[serde(default)]
488    pub bc_segments: Option<Vec<CardBcSegmentV1>>,
489    /// Filesystem path to a caliber-specific BC5D correction table
490    /// (`bc5d_<caliber>.bin`, the exact format the CLI's `--bc-table-dir`
491    /// consumes). The table is CRC-verified, a velocity-keyed BC schedule is
492    /// generated for this load (same ladder as the CLI), and the scalar BC gets the
493    /// table's muzzle correction as the interior-gap fallback. Ignored when
494    /// `bc_segments` is supplied. Not available on wasm32 builds (no filesystem);
495    /// there it is rejected with an invalid-request error.
496    #[serde(default)]
497    pub bc5d_table_path: Option<String>,
498
499    /// Presentation-only options for the printable PDF dope card (`card.pdf`). Nothing in
500    /// here touches the ballistics: the numbers in the PDF are the rows
501    /// [`range_table_v1`] returns, computed by the same call.
502    ///
503    /// It lives on the shared request — rather than in a `card.pdf`-only wrapper type — so
504    /// an app stores ONE `CardRequestV1` per saved card and replays that exact document
505    /// against either surface: `card.range_table` for the screen, `card.pdf` for the
506    /// printout. The on-screen commands ignore this block.
507    ///
508    /// Deliberately NOT `#[cfg(feature = "pdf")]`, even though everything that consumes it
509    /// is: `CardRequestV1` denies unknown fields, so gating the field would make a
510    /// pdf-less build REJECT a stored request that carries it, breaking exactly the
511    /// round-trip the field exists to support. A pdf-less build parses it and never reads
512    /// it; only the `card.pdf` command itself disappears.
513    #[serde(default)]
514    pub pdf: Option<PdfCardOptionsV1>,
515}
516
517/// Presentation knobs for the PDF dope card: the header/footer labels the printed card
518/// carries and the table's font size. The ballistics axes it prints (elevation unit,
519/// windage unit, click graduations, tracking CFs, zero-set biases) are NOT here — they are
520/// already fields on [`CardRequestV1`], shared with the on-screen card, precisely so the
521/// two cannot disagree about what a row means.
522///
523/// Every field is optional and defaults to the CLI dope card's own default.
524#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
525#[serde(deny_unknown_fields)]
526pub struct PdfCardOptionsV1 {
527    /// Leading text of header line 1 — the CLI's rifle/profile name. Default
528    /// `"Dope Card"`. Long headers are truncated to fit the page.
529    #[serde(default)]
530    pub title: Option<String>,
531    /// Header `Loc:` label. Default empty.
532    #[serde(default)]
533    pub location: Option<String>,
534    /// Footer `Powder:` label. Default empty.
535    #[serde(default)]
536    pub powder: Option<String>,
537    /// Footer `Bullet:` label. Default empty.
538    #[serde(default)]
539    pub bullet: Option<String>,
540    /// Crossing-target speed for the Lead column, in the request's `wind_speed_unit` — the
541    /// same convention as the CLI's `--target-speed`
542    /// (MBA-1325). The lead is `speed * time-of-flight` for a full-value 90° crossing,
543    /// held on the windage axis, exactly as `trajectory -o pdf` computes it.
544    ///
545    /// Absent means "this card carries no lead data": the Lead column renders as em-dashes
546    /// rather than a column of confident-looking zeroes. An explicit `0.0` is a different
547    /// statement — a stationary target, whose lead genuinely is zero — and prints zeroes,
548    /// matching the CLI's `--target-speed 0` default.
549    #[serde(default)]
550    pub target_speed: Option<f64>,
551    /// Data-table font scale. Must be finite and within
552    /// [`crate::pdf_dope_card::FONT_SCALE_RANGE`] (0.5..=3.0); out-of-band values are
553    /// rejected rather than silently clamped, so what a stored request asks for is what it
554    /// gets. Mutually exclusive with `font_preset`. Default 1.0.
555    #[serde(default)]
556    pub font_scale: Option<f64>,
557    /// Named font scale: `"small"` (0.8), `"medium"` (1.0), `"large"` (1.4) — the CLI's
558    /// `--font-preset` vocabulary, single-letter aliases included. An unrecognized preset
559    /// is an error here (the CLI warns and falls back to medium; a stored request should
560    /// not silently render at a size it did not ask for). Mutually exclusive with
561    /// `font_scale`.
562    #[serde(default)]
563    pub font_preset: Option<String>,
564    /// Render the data rows in bold. Default false.
565    #[serde(default)]
566    pub bold_data: bool,
567}
568
569/// One velocity band of an explicit velocity-keyed BC schedule
570/// (`CardRequestV1::bc_segments`). `velocity_min`/`velocity_max` are in the
571/// request's `velocity_unit` and must satisfy `velocity_min < velocity_max`;
572/// `bc` is the BC (for the request's `drag_model`) that applies inside the band.
573/// `bc` must lie in 0.0001..=100. Responses state the bands a card flew in the same shape,
574/// in [`CardBcLadderV1::segments`] (MBA-1601).
575#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
576#[serde(deny_unknown_fields)]
577pub struct CardBcSegmentV1 {
578    pub velocity_min: f64,
579    pub velocity_max: f64,
580    pub bc: f64,
581}
582
583/// The BC ladder a card's rows flew (MBA-1601): [`CardResponseV1::bc_ladder`].
584///
585/// Under a ladder the scalar [`CardResponseV1::bc_for_solve`] is only the interior-gap
586/// fallback, so this block is what states the BCs the card actually flew.
587#[derive(Debug, Clone, PartialEq, Serialize)]
588pub struct CardBcLadderV1 {
589    /// Where the ladder came from: `"bc_segments"` (the request's own bands) or
590    /// `"bc5d_table"` (the ladder `bc5d_table_path` generated for this load).
591    pub source: &'static str,
592    /// The bands as flown, `velocity_min`/`velocity_max` in the response's `units.velocity`.
593    /// For `bc_segments` these are the request's bands verbatim and in request order; for
594    /// `bc5d_table` the generated ladder, fastest band first.
595    pub segments: Vec<CardBcSegmentV1>,
596    /// True when some airspeed between the slowest and the fastest band lies in no band: there,
597    /// and blended into the bands within up to 25 fps either side of a gap edge, `bc_for_solve`
598    /// flies. False means `bc_for_solve` never flew on this card. It describes the ladder, not
599    /// the flight: a gap the card's airspeeds never reach still reads true.
600    pub interior_gap: bool,
601}
602
603/// The `sight_height` sentinel for "not supplied".
604///
605/// A serde field default is a nullary function: it cannot see the request, so it cannot know
606/// which `sight_height_unit` the figure would have to be in. NaN stands in until `resolve()`
607/// swaps it for `Units::default_sight_height`, which does know.
608fn sight_height_unset() -> f64 {
609    f64::NAN
610}
611fn default_humidity() -> f64 {
612    50.0
613}
614fn default_cf() -> f64 {
615    1.0
616}
617
618/// Drag model selector mirroring the CLI's `--drag-model` strings.
619#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
620#[serde(rename_all = "lowercase")]
621pub enum DragModelV1 {
622    G1,
623    #[default]
624    G7,
625    G2,
626    G5,
627    G6,
628    G8,
629    GI,
630    GS,
631    RA4,
632}
633
634impl From<DragModelV1> for DragModel {
635    fn from(v: DragModelV1) -> Self {
636        match v {
637            DragModelV1::G1 => DragModel::G1,
638            DragModelV1::G7 => DragModel::G7,
639            DragModelV1::G2 => DragModel::G2,
640            DragModelV1::G5 => DragModel::G5,
641            DragModelV1::G6 => DragModel::G6,
642            DragModelV1::G8 => DragModel::G8,
643            DragModelV1::GI => DragModel::GI,
644            DragModelV1::GS => DragModel::GS,
645            DragModelV1::RA4 => DragModel::RA4,
646        }
647    }
648}
649
650#[derive(Debug, Clone, Serialize)]
651pub struct CardRowV1 {
652    /// Range in the request's `distance_unit`; `CardUnitsBlockV1::distance` names it.
653    pub range: f64,
654    #[serde(skip_serializing_if = "Option::is_none")]
655    pub drop_linear: Option<f64>,
656    #[serde(skip_serializing_if = "Option::is_none")]
657    pub drop_adj: Option<f64>,
658    #[serde(skip_serializing_if = "Option::is_none")]
659    pub come_up: Option<f64>,
660    #[serde(skip_serializing_if = "Option::is_none")]
661    pub wind_linear: Option<f64>,
662    #[serde(skip_serializing_if = "Option::is_none")]
663    pub wind_adj: Option<f64>,
664    #[serde(skip_serializing_if = "Option::is_none")]
665    pub velocity: Option<f64>,
666    #[serde(skip_serializing_if = "Option::is_none")]
667    pub energy: Option<f64>,
668    #[serde(skip_serializing_if = "Option::is_none")]
669    pub time: Option<f64>,
670    #[serde(skip_serializing_if = "Vec::is_empty", default)]
671    pub wind_columns: Vec<f64>,
672}
673
674#[derive(Debug, Clone, Serialize)]
675pub struct CardUnitsBlockV1 {
676    pub distance: &'static str,
677    pub velocity: &'static str,
678    pub energy: &'static str,
679    pub drop: &'static str,
680    pub wind_speed: &'static str,
681    pub elevation_adjustment: String,
682    pub windage_adjustment: String,
683}
684
685#[derive(Debug, Clone, Serialize)]
686pub struct CardResponseV1 {
687    pub schema_version: u32,
688    pub kind: &'static str,
689    pub zero_distance: f64,
690    /// The scalar BC fed to the zero solve and every sampled flight: the request's published
691    /// `ballistic_coefficient`, or, when `bc5d_table_path` generated a ladder, that BC with
692    /// the table's muzzle-velocity correction applied.
693    ///
694    /// With no [`Self::bc_ladder`] it is the BC every row flew, and the printed footer's
695    /// `BC:`. With a `bc_ladder` it is only the interior-gap fallback: it flies only where
696    /// `bc_ladder.interior_gap` allows, and never under a gapless ladder (every ladder a
697    /// correction table builds), so it must not be presented as the card's BC (MBA-1601).
698    pub bc_for_solve: f64,
699    /// The BC ladder these rows flew, present only when one fed the solve: the request's
700    /// `bc_segments`, or the ladder its `bc5d_table_path` generated (MBA-1601).
701    ///
702    /// Additive and absent on every card without a ladder, so those responses are
703    /// byte-identical to before. A saved card has to be able to state the BCs its numbers
704    /// came from long after the correction table has been replaced on disk, and under a
705    /// ladder `bc_for_solve` alone does not do that.
706    #[serde(skip_serializing_if = "Option::is_none", default)]
707    pub bc_ladder: Option<CardBcLadderV1>,
708    pub units: CardUnitsBlockV1,
709    /// Wind card only: the swept speeds, one per `wind_columns` entry.
710    #[serde(skip_serializing_if = "Vec::is_empty", default)]
711    pub wind_speeds: Vec<f64>,
712    /// Wind card only: one block of rows per wind angle.
713    #[serde(skip_serializing_if = "Vec::is_empty", default)]
714    pub wind_angles_deg: Vec<f64>,
715    pub rows: Vec<CardRowV1>,
716    /// Wind card with multiple angles: rows for angles beyond the first.
717    #[serde(skip_serializing_if = "Vec::is_empty", default)]
718    pub extra_angle_rows: Vec<Vec<CardRowV1>>,
719    /// Present ONLY when the solved flight stopped short of the rows requested, in which case
720    /// `rows` holds every row it did reach and nothing past them (MBA-1476 follow-up).
721    ///
722    /// Additive and absent on every card that ran to its requested `end`, so a caller reading
723    /// an untruncated card sees byte-identical JSON to before. It is a field rather than only
724    /// a log line because the apps and the Flask layer consume this shape: a card that quietly
725    /// returns fewer rows than were asked for, with nothing on the response saying so, is the
726    /// same class of silent failure as the substituted row this work removed.
727    #[serde(skip_serializing_if = "Option::is_none", default)]
728    pub truncation: Option<CardTruncationV1>,
729}
730
731/// The wire form of [`CardTruncation`]: the rows a card could not print and why.
732/// Distances are in the response's own `units.distance`.
733#[derive(Debug, Clone, Copy, Serialize)]
734pub struct CardTruncationV1 {
735    /// The furthest row the request asked for.
736    pub requested_end: f64,
737    /// The last row actually present in `rows`.
738    pub last_row: f64,
739    /// How far the solved flight got — a property of the load, not of the request.
740    pub reach: f64,
741}
742
743impl CardTruncationV1 {
744    fn new(truncation: CardTruncation) -> Self {
745        Self {
746            requested_end: truncation.requested_end,
747            last_row: truncation.last_row,
748            reach: truncation.reach,
749        }
750    }
751}
752
753/// Back to the engine type, so the wording lives on [`CardTruncation`] alone and no surface
754/// has to restate the field mapping in order to say the same sentence.
755impl From<CardTruncationV1> for CardTruncation {
756    fn from(wire: CardTruncationV1) -> Self {
757        Self {
758            requested_end: wire.requested_end,
759            last_row: wire.last_row,
760            reach: wire.reach,
761        }
762    }
763}
764
765#[derive(Debug, thiserror::Error)]
766pub enum CardServiceError {
767    #[error("invalid request: {0}")]
768    InvalidRequest(String),
769    #[error("zero solve failed: {0}")]
770    ZeroSolve(String),
771    #[error("trajectory failed: {0}")]
772    Trajectory(String),
773    /// PDF rendering itself failed (font load/parse, or an empty row set). Gated with the
774    /// only code path that can produce it, so a build without `pdf` sees an unchanged enum.
775    #[cfg(feature = "pdf")]
776    #[error("pdf generation failed: {0}")]
777    Pdf(String),
778    /// The card is too big to print ([`MAX_PDF_ROWS`] / [`MAX_PDF_PAGES`]). A resource
779    /// refusal, not a failure: the request was well formed and the caller can be told
780    /// exactly what is true of the card (how many rows, how many pages), so the message is
781    /// carried verbatim rather than prefixed.
782    #[cfg(feature = "pdf")]
783    #[error("{0}")]
784    TooLarge(String),
785}
786
787// --- Unit conversions: byte-identical constants to the CLI's UnitConverter ---
788//
789// One resolved unit per dimension, and one place where a unit becomes a factor. Every
790// imperial arm and every metric arm below is the SAME EXPRESSION the single-scalar version
791// of this module used for that dimension — the imperial arm for the dimensions the imperial
792// preset fills, the metric arm for the metric preset's — so a request that states no
793// per-dimension unit performs exactly the multiplications it always performed, in the same
794// order, and lands on the same bits. The only genuinely new arms are the values neither
795// preset produces (cm, km/h, knots, and feet of altitude), which no pre-existing request can
796// select.
797//
798// The reverse conversions are deliberately NOT expressed as `to_metric` composed with a
799// division. `Mph -> v` and `Mph -> (v * 0.44704) / 0.44704` are not the same f64, and the
800// pre-existing behaviour is the former.
801
802/// m/s per km/h.
803const KPH_TO_MPS: f64 = 1.0 / 3.6;
804/// m/s per knot: one nautical mile (1852 m) per hour, exactly.
805const KNOTS_TO_MPS: f64 = 1852.0 / 3600.0;
806
807struct Units {
808    distance: CardDistanceUnit,
809    velocity: CardVelocityUnit,
810    mass: CardMassUnit,
811    diameter: CardDiameterUnit,
812    sight_height: CardLinearUnit,
813    drop: CardLinearUnit,
814    wind_speed: CardWindSpeedUnit,
815    temperature: CardTemperatureUnit,
816    pressure: CardPressureUnit,
817    energy: CardEnergyUnit,
818    altitude: CardAltitudeUnit,
819}
820
821impl Units {
822    /// Settle every dimension: the request's own field where it states one, otherwise what
823    /// the [`CardUnits`] preset implies.
824    ///
825    /// Pure, cheap, and free of validation, so the PDF path can settle the axes it prints in
826    /// the header without repeating the request checks `resolve_inner` performs.
827    fn resolve(req: &CardRequestV1) -> Self {
828        let imperial = req.units == CardUnits::Imperial;
829        let linear = if imperial {
830            CardLinearUnit::Inches
831        } else {
832            CardLinearUnit::Mm
833        };
834        Self {
835            distance: req.distance_unit.unwrap_or(if imperial {
836                CardDistanceUnit::Yards
837            } else {
838                CardDistanceUnit::Meters
839            }),
840            velocity: req.velocity_unit.unwrap_or(if imperial {
841                CardVelocityUnit::Fps
842            } else {
843                CardVelocityUnit::Mps
844            }),
845            mass: req.mass_unit.unwrap_or(if imperial {
846                CardMassUnit::Grains
847            } else {
848                CardMassUnit::Grams
849            }),
850            diameter: req.diameter_unit.unwrap_or(if imperial {
851                CardDiameterUnit::Inches
852            } else {
853                CardDiameterUnit::Mm
854            }),
855            sight_height: req.sight_height_unit.unwrap_or(linear),
856            drop: req.drop_unit.unwrap_or(linear),
857            wind_speed: req.wind_speed_unit.unwrap_or(if imperial {
858                CardWindSpeedUnit::Mph
859            } else {
860                CardWindSpeedUnit::Mps
861            }),
862            temperature: req.temperature_unit.unwrap_or(if imperial {
863                CardTemperatureUnit::Fahrenheit
864            } else {
865                CardTemperatureUnit::Celsius
866            }),
867            pressure: req.pressure_unit.unwrap_or(if imperial {
868                CardPressureUnit::InHg
869            } else {
870                CardPressureUnit::HPa
871            }),
872            energy: req.energy_unit.unwrap_or(if imperial {
873                CardEnergyUnit::FtLb
874            } else {
875                CardEnergyUnit::Joules
876            }),
877            // NOT `imperial`-dependent: `altitude` has always been metres in both systems.
878            // See CardRequestV1::altitude_unit for why the preset must never reach it.
879            altitude: req.altitude_unit.unwrap_or(CardAltitudeUnit::Meters),
880        }
881    }
882
883    // --- into SI, for the solver ---
884
885    fn velocity_to_metric(&self, v: f64) -> f64 {
886        match self.velocity {
887            CardVelocityUnit::Fps => v * 0.3048,
888            CardVelocityUnit::Mps => v,
889        }
890    }
891    fn mass_to_kg(&self, v: f64) -> f64 {
892        match self.mass {
893            CardMassUnit::Grains => v * GRAINS_TO_KG,
894            CardMassUnit::Grams => v * 0.001,
895        }
896    }
897    fn distance_to_metric(&self, v: f64) -> f64 {
898        match self.distance {
899            CardDistanceUnit::Yards => v * 0.9144,
900            CardDistanceUnit::Meters => v,
901        }
902    }
903    fn linear_to_metric(unit: CardLinearUnit, v: f64) -> f64 {
904        match unit {
905            CardLinearUnit::Inches => v * 0.0254,
906            CardLinearUnit::Cm => v * 0.01,
907            CardLinearUnit::Mm => v * 0.001,
908        }
909    }
910    fn diameter_to_metric(&self, v: f64) -> f64 {
911        match self.diameter {
912            CardDiameterUnit::Inches => v * 0.0254,
913            CardDiameterUnit::Mm => v * 0.001,
914        }
915    }
916    fn sight_height_to_metric(&self, v: f64) -> f64 {
917        Self::linear_to_metric(self.sight_height, v)
918    }
919    fn drop_to_metric(&self, v: f64) -> f64 {
920        Self::linear_to_metric(self.drop, v)
921    }
922    fn wind_to_metric(&self, v: f64) -> f64 {
923        match self.wind_speed {
924            CardWindSpeedUnit::Mph => v * 0.44704,
925            CardWindSpeedUnit::Mps => v,
926            CardWindSpeedUnit::Kph => v * KPH_TO_MPS,
927            CardWindSpeedUnit::Knots => v * KNOTS_TO_MPS,
928        }
929    }
930    fn temperature_to_metric(&self, v: f64) -> f64 {
931        match self.temperature {
932            CardTemperatureUnit::Fahrenheit => (v - 32.0) * 5.0 / 9.0,
933            CardTemperatureUnit::Celsius => v,
934        }
935    }
936    fn pressure_to_metric(&self, v: f64) -> f64 {
937        match self.pressure {
938            CardPressureUnit::InHg => v * 33.8639,
939            CardPressureUnit::HPa => v,
940        }
941    }
942    /// `altitude` in metres, which is what [`AtmosphericConditions::altitude`] wants and
943    /// what every request that predates `altitude_unit` already supplied.
944    fn altitude_to_metric(&self, v: f64) -> f64 {
945        match self.altitude {
946            CardAltitudeUnit::Feet => v * 0.3048,
947            CardAltitudeUnit::Meters => v,
948        }
949    }
950
951    // --- back out, for the printed row ---
952
953    fn velocity_from_metric(&self, v: f64) -> f64 {
954        match self.velocity {
955            CardVelocityUnit::Fps => v / 0.3048,
956            CardVelocityUnit::Mps => v,
957        }
958    }
959    fn distance_from_metric(&self, v: f64) -> f64 {
960        match self.distance {
961            CardDistanceUnit::Yards => v / 0.9144,
962            CardDistanceUnit::Meters => v,
963        }
964    }
965    fn energy_from_metric(&self, v: f64) -> f64 {
966        match self.energy {
967            CardEnergyUnit::FtLb => v * 0.737562,
968            CardEnergyUnit::Joules => v,
969        }
970    }
971    fn drop_linear_from_metric(&self, v: f64) -> f64 {
972        match self.drop {
973            CardLinearUnit::Inches => v / 0.0254,
974            CardLinearUnit::Cm => v * 100.0,
975            CardLinearUnit::Mm => v * 1000.0,
976        }
977    }
978
979    // --- column labels; the response's `units` block IS this, field for field ---
980
981    /// Label for the distance axis, for error messages that must name a range.
982    fn distance_label(&self) -> &'static str {
983        match self.distance {
984            CardDistanceUnit::Yards => "yd",
985            CardDistanceUnit::Meters => "m",
986        }
987    }
988    fn velocity_label(&self) -> &'static str {
989        match self.velocity {
990            CardVelocityUnit::Fps => "fps",
991            CardVelocityUnit::Mps => "m/s",
992        }
993    }
994    fn energy_label(&self) -> &'static str {
995        match self.energy {
996            CardEnergyUnit::FtLb => "ft-lb",
997            CardEnergyUnit::Joules => "J",
998        }
999    }
1000    fn drop_label(&self) -> &'static str {
1001        match self.drop {
1002            CardLinearUnit::Inches => "in",
1003            CardLinearUnit::Cm => "cm",
1004            CardLinearUnit::Mm => "mm",
1005        }
1006    }
1007    fn wind_speed_label(&self) -> &'static str {
1008        match self.wind_speed {
1009            CardWindSpeedUnit::Mph => "mph",
1010            CardWindSpeedUnit::Mps => "m/s",
1011            CardWindSpeedUnit::Kph => "km/h",
1012            CardWindSpeedUnit::Knots => "kn",
1013        }
1014    }
1015
1016    // --- the CLI's own axes, for the call sites that are pinned to CLI constants ---
1017    //
1018    // These do NOT reuse the conversions above. The BC5D path and the PDF header each
1019    // hardcode a factor that `main.rs` hardcodes at the same call site (3.280_839_895 vs
1020    // 1/0.3048; 15.4324 vs the exact grains-per-gram), and the golden tests pin the CLI's
1021    // digits. Sharing one factor between them would change numbers this work must not.
1022    //
1023    // Each of these helpers is compiled exactly where its call site is, rather than being
1024    // blanket-allowed as dead code, so that a helper which stops being called anywhere is
1025    // still a warning. For most of them that means a cfg attribute; `velocity_to_fps_bc5d`
1026    // carries none because its call site is the always-compiled `bc_segments` arm of
1027    // `resolve_bc_schedule`, so it is reached in every build.
1028
1029    /// Display velocity -> fps using `parse_bc_segment`'s factor.
1030    fn velocity_to_fps_bc5d(&self, v: f64) -> f64 {
1031        match self.velocity {
1032            CardVelocityUnit::Fps => v,
1033            CardVelocityUnit::Mps => v * 3.280_839_895,
1034        }
1035    }
1036    /// fps -> display velocity, the inverse of [`Self::velocity_to_fps_bc5d`], for the band
1037    /// edges of a generated BC5D ladder (which no wasm32 build has).
1038    #[cfg(not(target_arch = "wasm32"))]
1039    fn velocity_from_fps_bc5d(&self, v: f64) -> f64 {
1040        match self.velocity {
1041            CardVelocityUnit::Fps => v,
1042            CardVelocityUnit::Mps => v / 3.280_839_895,
1043        }
1044    }
1045    /// Bullet weight -> grains using the CLI's BC5D/PDF factor.
1046    #[cfg(any(feature = "pdf", not(target_arch = "wasm32")))]
1047    fn mass_to_grains_cli(&self, v: f64) -> f64 {
1048        match self.mass {
1049            CardMassUnit::Grains => v,
1050            CardMassUnit::Grams => v * 15.4324,
1051        }
1052    }
1053    /// Bullet diameter -> inches, the BC5D table's caliber axis (which no wasm32 build has,
1054    /// there being no filesystem to read a table from).
1055    #[cfg(not(target_arch = "wasm32"))]
1056    fn diameter_to_inches(&self, v: f64) -> f64 {
1057        match self.diameter {
1058            CardDiameterUnit::Inches => v,
1059            CardDiameterUnit::Mm => v / 25.4,
1060        }
1061    }
1062    /// Muzzle velocity -> fps for the PDF header (`main.rs` divides by 0.3048 here).
1063    #[cfg(feature = "pdf")]
1064    fn velocity_to_fps_pdf(&self, v: f64) -> f64 {
1065        match self.velocity {
1066            CardVelocityUnit::Fps => v,
1067            CardVelocityUnit::Mps => v / 0.3048,
1068        }
1069    }
1070    /// Pressure -> inHg for the PDF header (`main.rs` uses 33.8639 on both PDF call sites,
1071    /// NOT `pdf_dope_card::INHG_TO_HPA`).
1072    #[cfg(feature = "pdf")]
1073    fn pressure_to_inhg(&self, v: f64) -> f64 {
1074        match self.pressure {
1075            CardPressureUnit::InHg => v,
1076            CardPressureUnit::HPa => v / 33.8639,
1077        }
1078    }
1079    /// Pressure -> hPa for the PDF header, same factor.
1080    #[cfg(feature = "pdf")]
1081    fn pressure_to_hpa(&self, v: f64) -> f64 {
1082        match self.pressure {
1083            CardPressureUnit::InHg => v * 33.8639,
1084            CardPressureUnit::HPa => v,
1085        }
1086    }
1087    /// Temperature -> °F for the PDF header.
1088    #[cfg(feature = "pdf")]
1089    fn temperature_to_fahrenheit(&self, v: f64) -> f64 {
1090        match self.temperature {
1091            CardTemperatureUnit::Fahrenheit => v,
1092            CardTemperatureUnit::Celsius => v * 9.0 / 5.0 + 32.0,
1093        }
1094    }
1095    /// Wind or crossing speed -> mph for the PDF header.
1096    #[cfg(feature = "pdf")]
1097    fn wind_to_mph(&self, v: f64) -> f64 {
1098        match self.wind_speed {
1099            CardWindSpeedUnit::Mph => v,
1100            CardWindSpeedUnit::Mps => v / 0.44704,
1101            CardWindSpeedUnit::Kph => v * KPH_TO_MPS / 0.44704,
1102            CardWindSpeedUnit::Knots => v * KNOTS_TO_MPS / 0.44704,
1103        }
1104    }
1105    /// Altitude -> feet for the PDF header, which reports the altitude the SOLVE used.
1106    #[cfg(feature = "pdf")]
1107    fn altitude_to_feet(&self, v: f64) -> f64 {
1108        match self.altitude {
1109            CardAltitudeUnit::Feet => v,
1110            CardAltitudeUnit::Meters => v / 0.3048,
1111        }
1112    }
1113
1114    // --- defaults that are themselves denominated ---
1115
1116    /// The CLI's 1.5-inch sight height, restated in this request's `sight_height_unit`.
1117    /// The millimetre figure is the module's long-standing 38.0 (a rounded 1.5 in, not a
1118    /// conversion of it), and the centimetre figure is that same length, exactly.
1119    fn default_sight_height(&self) -> f64 {
1120        match self.sight_height {
1121            CardLinearUnit::Inches => 1.5,
1122            CardLinearUnit::Cm => 3.8,
1123            CardLinearUnit::Mm => 38.0,
1124        }
1125    }
1126    /// The CLI's 59 °F / 15 °C standard temperature, in this request's `temperature_unit`.
1127    fn default_temperature(&self) -> f64 {
1128        match self.temperature {
1129            CardTemperatureUnit::Fahrenheit => 59.0,
1130            CardTemperatureUnit::Celsius => 15.0,
1131        }
1132    }
1133    /// The CLI's 29.92 inHg / 1013.25 hPa standard pressure, in this request's
1134    /// `pressure_unit`.
1135    fn default_pressure(&self) -> f64 {
1136        match self.pressure {
1137            CardPressureUnit::InHg => 29.92,
1138            CardPressureUnit::HPa => 1013.25,
1139        }
1140    }
1141}
1142
1143struct Resolved {
1144    u: Units,
1145    velocity_m: f64,
1146    mass_kg: f64,
1147    diameter_m: f64,
1148    sight_height_m: f64,
1149    zero_distance_m: f64,
1150    zero_poi_vertical_m: f64,
1151    zero_poi_horizontal_m: f64,
1152    sight_offset_lateral_m: f64,
1153    temperature_c: f64,
1154    pressure_hpa: f64,
1155    /// Which of `temperature` / `pressure` the request carried (0.49.0). A field the request
1156    /// carried is a reading and flies as given; an absent one is the default above, resolved
1157    /// by the legacy rule (ICAO standard air at a real altitude).
1158    station_presence: StationAtmospherePresence,
1159    /// Site elevation in metres — which is what `altitude` already was for every request
1160    /// that states no `altitude_unit`, so this is a rename of an identity, not a conversion.
1161    altitude_m: f64,
1162    end_m: f64,
1163    elevation_click: Option<ClickValue>,
1164    windage_click: Option<ClickValue>,
1165    windage_unit: AdjustmentUnit,
1166    drag_model: DragModel,
1167    /// Scalar BC actually fed to the zero solve and every sampled run. Equals the
1168    /// request's `ballistic_coefficient` unless a BC5D table applied its muzzle
1169    /// correction (CLI parity: the scalar is the schedule's interior-gap fallback).
1170    bc_for_solve: f64,
1171    /// Velocity-keyed BC schedule (velocities in fps, the unit the solver compares
1172    /// against), from explicit `bc_segments` or generated from `bc5d_table_path`.
1173    bc_segments_fps: Option<Vec<BCSegmentData>>,
1174    /// The same schedule as the response states it ([`CardResponseV1::bc_ladder`]).
1175    bc_ladder: Option<CardBcLadderV1>,
1176}
1177
1178fn resolve(req: &CardRequestV1) -> Result<Resolved, CardServiceError> {
1179    resolve_inner(req, true)
1180}
1181
1182/// [`resolve`] WITHOUT the BC-schedule step — the request's declared axes, click
1183/// graduations and unit conversions, and nothing that touches the filesystem.
1184///
1185/// This is the resolve `card.pdf` performs when it prints caller-supplied rows: those rows
1186/// were computed elsewhere (they are the stored `card.range_table` response), so opening
1187/// `bc5d_table_path` here would do nothing but couple a reprint to whether that file is
1188/// still on the device — which is exactly the coupling printing stored rows exists to
1189/// break. `bc_for_solve` is left at the request's published BC and `bc_ladder` at `None`;
1190/// neither is used for the footer on that path (the stored card's own are).
1191#[cfg(feature = "pdf")]
1192fn resolve_axes_only(req: &CardRequestV1) -> Result<Resolved, CardServiceError> {
1193    resolve_inner(req, false)
1194}
1195
1196fn resolve_inner(
1197    req: &CardRequestV1,
1198    load_bc_schedule: bool,
1199) -> Result<Resolved, CardServiceError> {
1200    let u = Units::resolve(req);
1201
1202    for (name, v) in [
1203        ("muzzle_velocity", req.muzzle_velocity),
1204        ("ballistic_coefficient", req.ballistic_coefficient),
1205        ("mass", req.mass),
1206        ("diameter", req.diameter),
1207        ("zero_distance", req.zero_distance),
1208        ("start", req.start),
1209        ("end", req.end),
1210        ("step", req.step),
1211    ] {
1212        if !v.is_finite() || v <= 0.0 {
1213            return Err(CardServiceError::InvalidRequest(format!(
1214                "{name} must be finite and positive, got {v}"
1215            )));
1216        }
1217    }
1218    if req.end < req.start {
1219        return Err(CardServiceError::InvalidRequest(
1220            "end must be >= start".into(),
1221        ));
1222    }
1223    if !(0.5..=1.5).contains(&req.elevation_cf) || !(0.5..=1.5).contains(&req.windage_cf) {
1224        return Err(CardServiceError::InvalidRequest(
1225            "tracking correction factors must lie in (0.5, 1.5)".into(),
1226        ));
1227    }
1228
1229    // Each of these three defaults is itself denominated, so it is taken from the dimension
1230    // that governs the field rather than from the preset (`Units` restates the CLI's own
1231    // figures per unit). `sight_height`'s sentinel is NaN because a serde field default
1232    // cannot see the request it belongs to.
1233    let sight_height = if req.sight_height.is_nan() {
1234        u.default_sight_height()
1235    } else {
1236        req.sight_height
1237    };
1238    // Presence is taken BEFORE the collapse below, which yields the legacy default for an
1239    // absent field (and must keep doing so: at sea level that default is what flies).
1240    let station_presence =
1241        StationAtmospherePresence::new(req.temperature.is_some(), req.pressure.is_some());
1242    let temperature = req.temperature.unwrap_or_else(|| u.default_temperature());
1243    let pressure = req.pressure.unwrap_or_else(|| u.default_pressure());
1244
1245    let parse_click =
1246        |label: &str, s: &Option<String>| -> Result<Option<ClickValue>, CardServiceError> {
1247            s.as_deref()
1248                .map(|raw| {
1249                    parse_click_value(raw)
1250                        .map_err(|e| CardServiceError::InvalidRequest(format!("{label}: {e}")))
1251                })
1252                .transpose()
1253        };
1254    let elevation_click = parse_click("elevation_click_value", &req.elevation_click_value)?;
1255    let windage_click =
1256        parse_click("windage_click_value", &req.windage_click_value)?.or(elevation_click);
1257
1258    if req.adjustment_unit == AdjustmentUnit::Clicks && elevation_click.is_none() {
1259        return Err(CardServiceError::InvalidRequest(
1260            "adjustment_unit 'clicks' requires elevation_click_value".into(),
1261        ));
1262    }
1263    let windage_unit = req.windage_unit.unwrap_or(req.adjustment_unit);
1264    if windage_unit == AdjustmentUnit::Clicks && windage_click.is_none() {
1265        return Err(CardServiceError::InvalidRequest(
1266            "windage_unit 'clicks' requires a click graduation".into(),
1267        ));
1268    }
1269
1270    let (bc_for_solve, bc_segments_fps, bc_ladder) = if load_bc_schedule {
1271        resolve_bc_schedule(req, &u)?
1272    } else {
1273        (req.ballistic_coefficient, None, None)
1274    };
1275
1276    Ok(Resolved {
1277        bc_for_solve,
1278        bc_segments_fps,
1279        bc_ladder,
1280        velocity_m: u.velocity_to_metric(req.muzzle_velocity),
1281        mass_kg: u.mass_to_kg(req.mass),
1282        diameter_m: u.diameter_to_metric(req.diameter),
1283        sight_height_m: u.sight_height_to_metric(sight_height),
1284        zero_distance_m: u.distance_to_metric(req.zero_distance),
1285        zero_poi_vertical_m: u.drop_to_metric(req.zero_poi_vertical),
1286        zero_poi_horizontal_m: u.drop_to_metric(req.zero_poi_horizontal),
1287        sight_offset_lateral_m: u.sight_height_to_metric(req.sight_offset_lateral),
1288        temperature_c: u.temperature_to_metric(temperature),
1289        pressure_hpa: u.pressure_to_metric(pressure),
1290        station_presence,
1291        altitude_m: u.altitude_to_metric(req.altitude),
1292        end_m: u.distance_to_metric(req.end),
1293        elevation_click,
1294        windage_click,
1295        windage_unit,
1296        drag_model: req.drag_model.into(),
1297        u,
1298    })
1299}
1300
1301/// `(scalar BC for the solve, optional fps-keyed schedule, the same schedule as the response
1302/// states it)`. The last two are both present or both absent.
1303type BcSchedule = (f64, Option<Vec<BCSegmentData>>, Option<CardBcLadderV1>);
1304
1305/// Resolve the velocity-keyed BC schedule for a card request, mirroring the CLI's
1306/// precedence exactly: explicit `bc_segments` win outright (and leave the scalar BC
1307/// untouched, like `--bc-segment` restoring the pre-table BC); otherwise a
1308/// `bc5d_table_path` generates the same segment ladder `--bc-table-dir` does and
1309/// applies the table's muzzle correction to the scalar fallback BC.
1310///
1311/// Returns a [`BcSchedule`].
1312fn resolve_bc_schedule(req: &CardRequestV1, u: &Units) -> Result<BcSchedule, CardServiceError> {
1313    if let Some(segments) = req.bc_segments.as_ref().filter(|s| !s.is_empty()) {
1314        if segments.len() > crate::solve_json::MAX_BC_SEGMENTS_V1 {
1315            return Err(CardServiceError::InvalidRequest(format!(
1316                "bc_segments must have at most {} bands, got {}",
1317                crate::solve_json::MAX_BC_SEGMENTS_V1,
1318                segments.len()
1319            )));
1320        }
1321        let (bc_min, bc_max) = crate::solve_json::limits::BALLISTIC_COEFFICIENT;
1322        let mut converted = Vec::with_capacity(segments.len());
1323        for (index, segment) in segments.iter().enumerate() {
1324            if !segment.velocity_min.is_finite()
1325                || !segment.velocity_max.is_finite()
1326                || !segment.bc.is_finite()
1327            {
1328                return Err(CardServiceError::InvalidRequest(format!(
1329                    "bc_segments[{index}]: velocity_min, velocity_max, and bc must be finite"
1330                )));
1331            }
1332            if segment.velocity_min >= segment.velocity_max {
1333                return Err(CardServiceError::InvalidRequest(format!(
1334                    "bc_segments[{index}]: velocity_min must be < velocity_max"
1335                )));
1336            }
1337            if segment.bc <= 0.0 {
1338                return Err(CardServiceError::InvalidRequest(format!(
1339                    "bc_segments[{index}]: bc must be > 0"
1340                )));
1341            }
1342            if !(bc_min..=bc_max).contains(&segment.bc) {
1343                return Err(CardServiceError::InvalidRequest(format!(
1344                    "bc_segments[{index}]: bc must be between {bc_min} and {bc_max}, got {}",
1345                    segment.bc
1346                )));
1347            }
1348            converted.push(BCSegmentData {
1349                // Display velocity -> fps with the exact factor the CLI's parse_bc_segment
1350                // uses; the band edges are in `velocity_unit`, like `muzzle_velocity`.
1351                velocity_min: u.velocity_to_fps_bc5d(segment.velocity_min),
1352                velocity_max: u.velocity_to_fps_bc5d(segment.velocity_max),
1353                bc_value: segment.bc,
1354            });
1355        }
1356        reject_overlapping_bc_segments(segments, u)?;
1357        // Built only once the ladder has passed every check above.
1358        let ladder = CardBcLadderV1 {
1359            source: "bc_segments",
1360            segments: segments.clone(),
1361            interior_gap: crate::bc_estimation::has_interior_gap(&converted),
1362        };
1363        return Ok((req.ballistic_coefficient, Some(converted), Some(ladder)));
1364    }
1365
1366    let Some(path) = req.bc5d_table_path.as_deref() else {
1367        return Ok((req.ballistic_coefficient, None, None));
1368    };
1369
1370    #[cfg(target_arch = "wasm32")]
1371    {
1372        let _ = (path, u);
1373        Err(CardServiceError::InvalidRequest(
1374            "bc5d_table_path is not supported on this target (no filesystem); supply \
1375             bc_segments instead"
1376                .into(),
1377        ))
1378    }
1379    #[cfg(not(target_arch = "wasm32"))]
1380    {
1381        // The table's CONTENT must be for this shot's caliber, not merely CRC-valid: a
1382        // wrong-caliber table silently biases every row of the card (see
1383        // Bc5dTable::ensure_caliber_matches), so a mismatch is refused here rather than
1384        // applied — and rather than quietly falling back to an uncorrected card, which
1385        // the caller could not distinguish from a corrected one.
1386        let diameter_in = u.diameter_to_inches(req.diameter);
1387        let table = crate::bc_table_5d::path_cache::load_verified_for_caliber(
1388            std::path::Path::new(path),
1389            diameter_in,
1390        )
1391        .map_err(|e| CardServiceError::InvalidRequest(format!("bc5d_table_path: {e}")))?;
1392
1393        // The BC5D axes are grains + fps; convert from the request's units the same
1394        // way the CLI does. The v2 tables carry only G1/G7 planes — anything else is
1395        // typed as G1, matching the CLI/WASM coercion.
1396        let weight_grains = u.mass_to_grains_cli(req.mass);
1397        let muzzle_fps = u.velocity_to_fps_bc5d(req.muzzle_velocity);
1398        let drag_type = if req.drag_model == DragModelV1::G7 {
1399            "G7"
1400        } else {
1401            "G1"
1402        };
1403        let base_bc = req.ballistic_coefficient;
1404
1405        match table.generate_segments(base_bc, drag_type, weight_grains, Some(muzzle_fps)) {
1406            Some(segments) => {
1407                // CLI parity: the scalar BC becomes the muzzle-corrected fallback for
1408                // interior coverage gaps (main.rs: trued_bc = base * muzzle_correction).
1409                let fallback_bc = table.get_effective_bc(
1410                    weight_grains,
1411                    base_bc,
1412                    muzzle_fps,
1413                    muzzle_fps,
1414                    drag_type,
1415                );
1416                // The generated ladder, stated in the request's own velocity unit.
1417                let ladder = CardBcLadderV1 {
1418                    source: "bc5d_table",
1419                    segments: segments
1420                        .iter()
1421                        .map(|s| CardBcSegmentV1 {
1422                            velocity_min: u.velocity_from_fps_bc5d(s.velocity_min),
1423                            velocity_max: u.velocity_from_fps_bc5d(s.velocity_max),
1424                            bc: s.bc_value,
1425                        })
1426                        .collect(),
1427                    interior_gap: crate::bc_estimation::has_interior_gap(&segments),
1428                };
1429                Ok((fallback_bc, Some(segments), Some(ladder)))
1430            }
1431            // A neutral table (every sampled cell ~= 1.0) carries no correction:
1432            // keep the published scalar BC, exactly like the CLI.
1433            None => Ok((base_bc, None, None)),
1434        }
1435    }
1436}
1437
1438/// Refuse a card ladder whose bands overlap (MBA-1600). Card accepts bands in any order, so they
1439/// are compared in `velocity_min` order; touching edges and gaps are legal. The engine's lookup
1440/// flies the FIRST listed band containing a speed and blends nothing across an overlap
1441/// (`bc_estimation.rs`), so an overlapping ladder would fly differently by listing order.
1442/// Compared in the request's `velocity_unit`: the fps conversion is monotonic, so a ladder that
1443/// does not overlap here cannot overlap after it. Messages quote the caller's own indices.
1444fn reject_overlapping_bc_segments(
1445    segments: &[CardBcSegmentV1],
1446    u: &Units,
1447) -> Result<(), CardServiceError> {
1448    let mut order: Vec<usize> = (0..segments.len()).collect();
1449    // Stable sort: equal velocity_min keep request order, so the later-listed band is reported.
1450    order.sort_by(|&a, &b| {
1451        segments[a]
1452            .velocity_min
1453            .total_cmp(&segments[b].velocity_min)
1454    });
1455    for pair in order.windows(2) {
1456        let (lower, upper) = (pair[0], pair[1]);
1457        let (lo, hi) = (&segments[lower], &segments[upper]);
1458        if hi.velocity_min < lo.velocity_max {
1459            let unit = u.velocity_label();
1460            return Err(CardServiceError::InvalidRequest(format!(
1461                "bc_segments[{upper}] ({} to {} {unit}) overlaps bc_segments[{lower}] ({} to {} \
1462                 {unit}): bands may touch or leave gaps but must not overlap",
1463                hi.velocity_min, hi.velocity_max, lo.velocity_min, lo.velocity_max
1464            )));
1465        }
1466    }
1467    Ok(())
1468}
1469
1470fn solve_zero(req: &CardRequestV1, r: &Resolved) -> Result<f64, CardServiceError> {
1471    let zero_inputs = BallisticInputs {
1472        bc_value: r.bc_for_solve,
1473        bc_type: r.drag_model,
1474        bullet_mass: r.mass_kg,
1475        muzzle_velocity: r.velocity_m,
1476        bullet_diameter: r.diameter_m,
1477        bullet_length: crate::truing::fallback_bullet_length_m(r.diameter_m, r.mass_kg),
1478        sight_height: r.sight_height_m,
1479        use_rk4: true,
1480        zero_poi_vertical_m: r.zero_poi_vertical_m,
1481        zero_poi_horizontal_m: r.zero_poi_horizontal_m,
1482        sight_offset_lateral_m: r.sight_offset_lateral_m,
1483        // The zero MUST be solved with the same velocity-keyed BC schedule the
1484        // sampled flights use (the 0.22.11 auto-zero lesson: a schedule that changes
1485        // early-flight drag would otherwise mis-zero every row).
1486        use_bc_segments: r.bc_segments_fps.is_some(),
1487        bc_segments_data: r.bc_segments_fps.clone(),
1488        ..Default::default()
1489    };
1490    let atmosphere = AtmosphericConditions {
1491        temperature: r.temperature_c,
1492        pressure: r.pressure_hpa,
1493        humidity: req.humidity,
1494        altitude: r.altitude_m,
1495    };
1496    crate::calculate_zero_angle_with_station_presence(
1497        zero_inputs,
1498        r.zero_distance_m,
1499        r.sight_height_m,
1500        WindConditions::default(),
1501        atmosphere,
1502        r.station_presence,
1503    )
1504    .map_err(|e| CardServiceError::ZeroSolve(e.to_string()))
1505}
1506
1507#[allow(clippy::too_many_arguments)]
1508fn sampled(
1509    req: &CardRequestV1,
1510    r: &Resolved,
1511    wind_speed_m: f64,
1512    wind_direction_deg: f64,
1513    zero_angle: f64,
1514) -> Result<SampledFlight, CardServiceError> {
1515    run_sampled_flight_with_station_presence(
1516        r.velocity_m,
1517        r.bc_for_solve,
1518        r.mass_kg,
1519        r.diameter_m,
1520        r.drag_model,
1521        r.sight_height_m,
1522        r.temperature_c,
1523        r.pressure_hpa,
1524        req.humidity,
1525        r.altitude_m,
1526        wind_speed_m,
1527        wind_direction_deg,
1528        // MBA-1476: at least one whole grid cell past the last row asked for — see
1529        // card_sample_max_range_m for why that headroom decides reachability.
1530        card_sample_max_range_m(r.end_m),
1531        // MBA-1476: the engine's own card grid, never the request's row spacing.
1532        CARD_SAMPLE_INTERVAL_M,
1533        zero_angle,
1534        // The same schedule as the zero solve above, on every card surface
1535        // (come-ups, range table, wind card) consistently.
1536        r.bc_segments_fps.clone(),
1537        None,
1538        None,
1539        r.zero_poi_vertical_m,
1540        r.zero_poi_horizontal_m,
1541        r.sight_offset_lateral_m,
1542        Some(r.zero_distance_m),
1543        r.station_presence,
1544    )
1545    .map_err(|e| CardServiceError::Trajectory(e.to_string()))
1546}
1547
1548/// Read one card row's sample off a solved flight, or `None` when the flight does not span it
1549/// (MBA-1476).
1550///
1551/// The nearest-sample-within-one-and-a-half-steps search this replaces answered a range the
1552/// flight had no sample for with a neighbour's numbers, invisibly. There is no tolerance to
1553/// tune here: [`sample_at_range`] interpolates exactly at the requested range whenever the
1554/// flight spans it, and a range it cannot span yields nothing at all. The caller ends the card
1555/// there and records a [`CardTruncation`] — it never substitutes, and it never drops the row
1556/// without saying so.
1557fn row_sample(
1558    samples: &[crate::trajectory_sampling::TrajectorySample],
1559    range_m: f64,
1560) -> Option<crate::trajectory_sampling::TrajectorySample> {
1561    sample_at_range(samples, range_m)
1562}
1563
1564/// The error a card returns when its flight reaches NONE of the rows requested — there is no
1565/// card to truncate, so this stays a refusal (MBA-1476 follow-up).
1566fn no_rows_error(first_row_display: f64, reach_m: f64, u: &Units) -> CardServiceError {
1567    CardServiceError::Trajectory(range_not_sampled_message(
1568        first_row_display,
1569        u.distance_from_metric(reach_m),
1570        u.distance_label(),
1571    ))
1572}
1573
1574/// Record the truncation of a card whose row loop stopped early, in display units.
1575fn truncation_of(
1576    requested_end: f64,
1577    last_row: f64,
1578    reach_m: f64,
1579    u: &Units,
1580) -> Option<CardTruncationV1> {
1581    Some(CardTruncationV1::new(CardTruncation {
1582        requested_end,
1583        last_row,
1584        reach: u.distance_from_metric(reach_m),
1585    }))
1586}
1587
1588/// The response's per-dimension label block: one label per dimension, read off the same
1589/// resolved [`Units`] the rows were converted with, so a column cannot be computed in one
1590/// unit and headed with another.
1591fn units_block(req: &CardRequestV1, u: &Units, windage_unit: AdjustmentUnit) -> CardUnitsBlockV1 {
1592    CardUnitsBlockV1 {
1593        distance: u.distance_label(),
1594        velocity: u.velocity_label(),
1595        energy: u.energy_label(),
1596        drop: u.drop_label(),
1597        wind_speed: u.wind_speed_label(),
1598        elevation_adjustment: adjustment_unit_label(req.adjustment_unit),
1599        windage_adjustment: adjustment_unit_label(windage_unit),
1600    }
1601}
1602
1603/// Come-ups: elevation dial per range plus the incremental come-up between rows.
1604/// Mirrors the CLI's `come-ups` command row-for-row.
1605pub fn come_ups_v1(req: &CardRequestV1) -> Result<CardResponseV1, CardServiceError> {
1606    let r = resolve(req)?;
1607    let zero_angle = solve_zero(req, &r)?;
1608    let flight = sampled(
1609        req,
1610        &r,
1611        r.u.wind_to_metric(req.wind_speed),
1612        req.wind_direction_deg,
1613        zero_angle,
1614    )?;
1615
1616    let mut rows: Vec<CardRowV1> = Vec::new();
1617    let mut truncation = None;
1618    let mut prev_drop_adj: f64 = 0.0;
1619    let mut current_range = req.start;
1620    while current_range <= req.end + 0.1 {
1621        let range_m = r.u.distance_to_metric(current_range);
1622        let Some(sample) = row_sample(&flight.samples, range_m) else {
1623            let Some(last) = rows.last() else {
1624                return Err(no_rows_error(current_range, flight.reach_m, &r.u));
1625            };
1626            truncation = truncation_of(req.end, last.range, flight.reach_m, &r.u);
1627            break;
1628        };
1629        let drop_yd = r.u.distance_from_metric(sample.drop_m);
1630        // MBA-1476: the row's OWN range, not a nearby sample's — the angular conversion
1631        // divides by the distance the row is labelled with.
1632        let drop_adj = adjustment_display(
1633            drop_yd,
1634            current_range,
1635            req.adjustment_unit,
1636            r.elevation_click,
1637            req.zero_set_elevation_bias_mil,
1638            req.elevation_cf,
1639        )
1640        .value;
1641        rows.push(CardRowV1 {
1642            range: current_range,
1643            drop_linear: None,
1644            drop_adj: Some(drop_adj),
1645            come_up: Some(drop_adj - prev_drop_adj),
1646            wind_linear: None,
1647            wind_adj: None,
1648            velocity: Some(r.u.velocity_from_metric(sample.velocity_mps)),
1649            energy: Some(r.u.energy_from_metric(sample.energy_j)),
1650            time: Some(sample.time_s),
1651            wind_columns: Vec::new(),
1652        });
1653        prev_drop_adj = drop_adj;
1654        current_range += req.step;
1655    }
1656
1657    Ok(CardResponseV1 {
1658        schema_version: CARD_SCHEMA_VERSION_V1,
1659        kind: "come_ups",
1660        zero_distance: req.zero_distance,
1661        bc_for_solve: r.bc_for_solve,
1662        bc_ladder: r.bc_ladder.clone(),
1663        units: units_block(req, &r.u, req.adjustment_unit),
1664        wind_speeds: Vec::new(),
1665        wind_angles_deg: Vec::new(),
1666        rows,
1667        extra_angle_rows: Vec::new(),
1668        truncation,
1669    })
1670}
1671
1672/// Range table: drop and wind on possibly different display axes.
1673/// Mirrors the CLI's `range-table` command row-for-row (two solves: with wind for
1674/// drift, without wind for the pure elevation axis).
1675pub fn range_table_v1(req: &CardRequestV1) -> Result<CardResponseV1, CardServiceError> {
1676    // Discards the PDF-only extras; see `range_table_rows`.
1677    range_table_rows(req, None).map(|(card, _lead, _bc)| card)
1678}
1679
1680/// The one range-table computation, shared by the on-screen `card.range_table` and the
1681/// printable `card.pdf`.
1682///
1683/// Returns `(the card, the Lead column, the scalar BC the solve ran with)`:
1684///
1685/// * The card is exactly what `card.range_table` serializes — `range_table_v1` is a
1686///   discarding wrapper, so the PDF's Range/Drop/Wind figures ARE the on-screen figures.
1687///   They are taken from here rather than recomputed because recomputing them is the one
1688///   way the printed card could ever disagree with the rows the shooter already read.
1689/// * The Lead column parallels `card.rows` one-to-one. `lead_target_speed` is in the
1690///   request's `wind_speed_unit`; `None` (no lead requested) yields all `None`, which the
1691///   PDF renders as em-dashes rather than fake zeroes.
1692/// * The BC is `Resolved::bc_for_solve` — the published BC unless a BC5D table applied its
1693///   muzzle correction — which the PDF footer prints when the card flew no ladder. The
1694///   footer must state the BC the numbers above it came from, not the one the request
1695///   nominated; under a ladder that is the card's `bc_ladder` range (MBA-1601), and this
1696///   scalar is only its interior-gap fallback.
1697fn range_table_rows(
1698    req: &CardRequestV1,
1699    lead_target_speed: Option<f64>,
1700) -> Result<(CardResponseV1, Vec<Option<f64>>, f64), CardServiceError> {
1701    let r = resolve(req)?;
1702    let zero_angle = solve_zero(req, &r)?;
1703    let wind_flight = sampled(
1704        req,
1705        &r,
1706        r.u.wind_to_metric(req.wind_speed),
1707        req.wind_direction_deg,
1708        zero_angle,
1709    )?;
1710    let no_wind_flight = sampled(req, &r, 0.0, 0.0, zero_angle)?;
1711    // Both flights feed every row, so the card can only run as far as the shorter of them.
1712    let reach_m = wind_flight.reach_m.min(no_wind_flight.reach_m);
1713    let lead_speed_mps = lead_target_speed.map(|speed| r.u.wind_to_metric(speed));
1714
1715    let mut rows: Vec<CardRowV1> = Vec::new();
1716    let mut lead_adj = Vec::new();
1717    let mut truncation = None;
1718    let mut current_range = req.start;
1719    while current_range <= req.end + 0.1 {
1720        let range_m = r.u.distance_to_metric(current_range);
1721        let (Some(nw), Some(w)) = (
1722            row_sample(&no_wind_flight.samples, range_m),
1723            row_sample(&wind_flight.samples, range_m),
1724        ) else {
1725            let Some(last) = rows.last() else {
1726                return Err(no_rows_error(current_range, reach_m, &r.u));
1727            };
1728            truncation = truncation_of(req.end, last.range, reach_m, &r.u);
1729            break;
1730        };
1731        // MBA-1476: the row's OWN range on both axes.
1732        let range_display = current_range;
1733        let drop_yd = r.u.distance_from_metric(nw.drop_m);
1734        let drop_adj = adjustment_display(
1735            drop_yd,
1736            range_display,
1737            req.adjustment_unit,
1738            r.elevation_click,
1739            req.zero_set_elevation_bias_mil,
1740            req.elevation_cf,
1741        )
1742        .value;
1743        let drift_yd = r.u.distance_from_metric(w.wind_drift_m);
1744        let wind_adj = windage_adjustment_display(
1745            drift_yd,
1746            range_display,
1747            r.windage_unit,
1748            r.windage_click,
1749            req.zero_set_windage_bias_mil,
1750            req.windage_cf,
1751        )
1752        .value;
1753        // Lead is the windage-axis hold for a full-value 90° crossing target:
1754        // `speed * time-of-flight` (MBA-1287's shared moving-target math), on the
1755        // no-wind sample whose time this row already reports. It is a COMPONENT
1756        // hold composed on top of the wind dial, which already carries the
1757        // zero-set bias, so it stays bias-free (bias 0.0) while still being
1758        // divided by the windage tracking CF — the same treatment
1759        // `main.rs::dope_card_row_from_sample` gives the CLI's Lead column.
1760        lead_adj.push(lead_speed_mps.map(|speed_mps| {
1761            let lead_display = r.u.distance_from_metric(
1762                crate::lead_from_tof(speed_mps, 90.0, nw.time_s, nw.distance_m).lead_m,
1763            );
1764            windage_adjustment_display(
1765                lead_display,
1766                range_display,
1767                r.windage_unit,
1768                r.windage_click,
1769                0.0,
1770                req.windage_cf,
1771            )
1772            .value
1773        }));
1774        rows.push(CardRowV1 {
1775            range: current_range,
1776            drop_linear: Some(r.u.drop_linear_from_metric(nw.drop_m)),
1777            drop_adj: Some(drop_adj),
1778            come_up: None,
1779            wind_linear: Some(r.u.drop_linear_from_metric(w.wind_drift_m)),
1780            wind_adj: Some(wind_adj),
1781            velocity: Some(r.u.velocity_from_metric(nw.velocity_mps)),
1782            energy: Some(r.u.energy_from_metric(nw.energy_j)),
1783            time: Some(nw.time_s),
1784            wind_columns: Vec::new(),
1785        });
1786        current_range += req.step;
1787    }
1788
1789    debug_assert_eq!(rows.len(), lead_adj.len(), "Lead column must parallel rows");
1790    Ok((
1791        CardResponseV1 {
1792            schema_version: CARD_SCHEMA_VERSION_V1,
1793            kind: "range_table",
1794            zero_distance: req.zero_distance,
1795            bc_for_solve: r.bc_for_solve,
1796            bc_ladder: r.bc_ladder.clone(),
1797            units: units_block(req, &r.u, r.windage_unit),
1798            wind_speeds: Vec::new(),
1799            wind_angles_deg: Vec::new(),
1800            rows,
1801            extra_angle_rows: Vec::new(),
1802            truncation,
1803        },
1804        lead_adj,
1805        r.bc_for_solve,
1806    ))
1807}
1808
1809/// Wind card: drift matrix, ranges x wind speeds, per wind-FROM angle.
1810/// Mirrors the CLI's `wind-card` command cell-for-cell. The windage axis uses
1811/// `adjustment_unit` (the CLI's wind-card has a single unit for the matrix).
1812pub fn wind_card_v1(req: &CardRequestV1) -> Result<CardResponseV1, CardServiceError> {
1813    if req.wind_speeds.is_empty() {
1814        return Err(CardServiceError::InvalidRequest(
1815            "wind_card requires at least one entry in wind_speeds".into(),
1816        ));
1817    }
1818    let r = resolve(req)?;
1819    let zero_angle = solve_zero(req, &r)?;
1820
1821    let angles: Vec<f64> = if req.wind_angles_deg.is_empty() {
1822        vec![90.0]
1823    } else {
1824        req.wind_angles_deg.clone()
1825    };
1826
1827    let mut ranges: Vec<f64> = Vec::new();
1828    let mut current = req.start;
1829    while current <= req.end + 0.1 {
1830        ranges.push(current);
1831        current += req.step;
1832    }
1833
1834    // Every angle/speed combination is its own flight and they do not all reach equally far
1835    // (a crosswind bleeds a little velocity), but the matrix has ONE range axis. So the card
1836    // runs to the shortest of them: `reachable` is the number of leading ranges every flight
1837    // supplies, and the ragged tails are trimmed once, below.
1838    let mut reachable = ranges.len();
1839    let mut reach_m = f64::INFINITY;
1840    let mut per_angle_drifts: Vec<Vec<Vec<f64>>> = Vec::new();
1841    for &angle_deg in &angles {
1842        let mut all_drifts: Vec<Vec<f64>> = vec![Vec::new(); ranges.len()];
1843        for &ws in &req.wind_speeds {
1844            let flight = sampled(req, &r, r.u.wind_to_metric(ws), angle_deg, zero_angle)?;
1845            reach_m = reach_m.min(flight.reach_m);
1846            for (ri, &range_display) in ranges.iter().enumerate().take(reachable) {
1847                let range_m = r.u.distance_to_metric(range_display);
1848                // MBA-1476: a range the flight cannot supply used to become a 0.0 cell —
1849                // indistinguishable from "no drift at all" on a wind card.
1850                let Some(sample) = row_sample(&flight.samples, range_m) else {
1851                    reachable = ri;
1852                    break;
1853                };
1854                let drift_yd = r.u.distance_from_metric(sample.wind_drift_m);
1855                let drift_adj = adjustment_display(
1856                    drift_yd,
1857                    range_display,
1858                    req.adjustment_unit,
1859                    r.windage_click,
1860                    req.zero_set_windage_bias_mil,
1861                    req.windage_cf,
1862                )
1863                .value;
1864                all_drifts[ri].push(drift_adj);
1865            }
1866        }
1867        per_angle_drifts.push(all_drifts);
1868    }
1869
1870    let truncation = if reachable < ranges.len() {
1871        if reachable == 0 {
1872            return Err(no_rows_error(ranges[0], reach_m, &r.u));
1873        }
1874        let last = ranges[reachable - 1];
1875        ranges.truncate(reachable);
1876        truncation_of(req.end, last, reach_m, &r.u)
1877    } else {
1878        None
1879    };
1880
1881    let mut per_angle_rows: Vec<Vec<CardRowV1>> = per_angle_drifts
1882        .into_iter()
1883        .map(|all_drifts| {
1884            ranges
1885                .iter()
1886                .zip(all_drifts)
1887                .map(|(&range, wind_columns)| CardRowV1 {
1888                    range,
1889                    drop_linear: None,
1890                    drop_adj: None,
1891                    come_up: None,
1892                    wind_linear: None,
1893                    wind_adj: None,
1894                    velocity: None,
1895                    energy: None,
1896                    time: None,
1897                    wind_columns,
1898                })
1899                .collect()
1900        })
1901        .collect();
1902
1903    let first = per_angle_rows.remove(0);
1904    Ok(CardResponseV1 {
1905        schema_version: CARD_SCHEMA_VERSION_V1,
1906        kind: "wind_card",
1907        zero_distance: req.zero_distance,
1908        bc_for_solve: r.bc_for_solve,
1909        bc_ladder: r.bc_ladder.clone(),
1910        units: units_block(req, &r.u, req.adjustment_unit),
1911        wind_speeds: req.wind_speeds.clone(),
1912        wind_angles_deg: angles,
1913        rows: first,
1914        extra_angle_rows: per_angle_rows,
1915        truncation,
1916    })
1917}
1918
1919// ---------------------------------------------------------------------------------------
1920// Printable PDF dope card (`card.pdf`), feature `pdf`.
1921//
1922// Two ways in, one renderer:
1923//
1924// * REPRINT — the caller supplies the rows: `StoredCardV1` carries the stored
1925//   `card.range_table` response verbatim. Nothing is solved, no correction table is opened,
1926//   and the footer's BC and provenance come from that document. Only under this construction
1927//   is a reprint of a saved card actually a reprint: it cannot drift when the engine is
1928//   bumped, or when the BC5D table file at the stored path is overwritten in place by a
1929//   table-set refresh.
1930// * SOLVE — no rows supplied: `range_table_rows` runs, which is the same call
1931//   `card.range_table` makes (`range_table_v1` is a discarding wrapper over it). This is the
1932//   pre-existing behaviour, unchanged, and what the CLI-shaped caller gets.
1933//
1934// Neither path recomputes an adjustment, a unit conversion or a trajectory that the card it
1935// prints already performed.
1936// ---------------------------------------------------------------------------------------
1937
1938/// The one card kind `card.pdf` can print, in the `kind` spelling
1939/// [`CardResponseV1::kind`] uses.
1940///
1941/// A come-ups card's Come-Up column and a wind card's swept drift matrix are not columns a
1942/// range-table card has, so a request for either is REFUSED rather than answered with a
1943/// range table — an `ok` response whose defining field was silently ignored is the defect
1944/// (a stored wind card exported as a range table asserted 0.0 drift on every row while the
1945/// screen showed up to -0.42 MIL).
1946#[cfg(feature = "pdf")]
1947pub const PDF_CARD_KIND: &str = "range_table";
1948
1949/// Rows a printable card may carry.
1950///
1951/// Checked from the row count itself, before a document exists — the byte cap
1952/// (`bridge::MAX_PDF_BYTES`) can only refuse a 26 MB `Vec<u8>` that has already been built
1953/// and paginated. At ~0.5 KiB/row over the ~815 KiB font floor this bound keeps every
1954/// accepted card comfortably inside that cap, which remains the backstop for the other way
1955/// to make a huge document: header/footer labels, which are drawn verbatim on every page.
1956#[cfg(feature = "pdf")]
1957pub const MAX_PDF_ROWS: usize = 5_000;
1958
1959/// Pages a printable card may run to. Bounds the one row set the row cap above cannot:
1960/// few enough rows, but at a font scale that fits very few of them per page.
1961#[cfg(feature = "pdf")]
1962pub const MAX_PDF_PAGES: usize = 60;
1963
1964/// Where the printed rows came from — reported so a caller can verify it got a reprint
1965/// rather than a re-solve, which is otherwise indistinguishable from the document.
1966#[cfg(feature = "pdf")]
1967#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1968pub enum PdfRowSource {
1969    /// Solved by this call, exactly as `card.range_table` would for the same request.
1970    Solve,
1971    /// Supplied by the caller: a stored `card.range_table` response, printed as-is.
1972    StoredRows,
1973}
1974
1975#[cfg(feature = "pdf")]
1976impl PdfRowSource {
1977    /// Wire spelling for the bridge's `source` field.
1978    pub fn as_str(self) -> &'static str {
1979        match self {
1980            Self::Solve => "solve",
1981            Self::StoredRows => "stored_rows",
1982        }
1983    }
1984}
1985
1986/// A card whose rows already exist: print THESE numbers instead of solving.
1987///
1988/// `card` is the stored `card.range_table` response, pasted verbatim — an app keeps that
1989/// document for every saved card and shows it on screen, so handing the same bytes back is
1990/// what makes the paper and the screen the same card by construction rather than by
1991/// coincidence. The two provenance strings are printed in the footer so a card in a
1992/// shooter's pocket can be reconciled with a screen afterwards; both are optional, and an
1993/// absent or empty one prints nothing at all rather than a placeholder.
1994#[cfg(feature = "pdf")]
1995#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
1996#[serde(deny_unknown_fields)]
1997pub struct StoredCardV1 {
1998    /// The stored response, exactly as this engine emitted it.
1999    pub card: StoredCardResponseV1,
2000    /// Engine version that produced the rows (`engine_version` from the bridge envelope of
2001    /// the call that produced them). Absent/empty prints no engine line.
2002    ///
2003    /// Since 0.49.0 the reprint header's air depends on this version; send the
2004    /// `engine_version` of the call that produced the rows. Rows from 0.49.0 or later flew a
2005    /// typed temperature or pressure as typed, so the header states it that way. Rows from an
2006    /// older engine, or from an absent, empty or unreadable version, flew the pre-0.49.0 rule
2007    /// (a typed 15 °C / 59 °F or 1013.25 hPa / 29.92 inHg at a real altitude was replaced by
2008    /// ICAO standard air), and the header states that air instead, so it never describes air
2009    /// the printed rows did not fly.
2010    #[serde(default)]
2011    pub engine_version: Option<String>,
2012    /// Correction-table version the rows were solved against — the published BC5D table-set
2013    /// version an app records at save time. Absent/empty prints no table line, which is the
2014    /// honest rendering of "this card used no correction table".
2015    #[serde(default)]
2016    pub bc5d_table_version: Option<String>,
2017}
2018
2019/// Whether stored card rows were solved by an engine that flies a typed station reading as
2020/// typed (0.49.0 or later), judged from [`StoredCardV1::engine_version`].
2021///
2022/// The version is read as `MAJOR.MINOR.PATCH`, ignoring a leading `v` and any `-pre` or
2023/// `+build` suffix. An absent, empty or unparseable version is `false`: such rows are treated
2024/// as pre-0.49.0 rows, which is what they are when an app recorded no version.
2025#[cfg(feature = "pdf")]
2026fn stored_rows_are_presence_aware(engine_version: &Option<String>) -> bool {
2027    let Some(version) = engine_version.as_deref() else {
2028        return false;
2029    };
2030    let version = version.trim();
2031    let version = version.strip_prefix('v').unwrap_or(version);
2032    let core = version.split(['-', '+']).next().unwrap_or("");
2033    let parts: Vec<&str> = core.split('.').collect();
2034    let [major, minor, patch] = parts.as_slice() else {
2035        return false;
2036    };
2037    let (Ok(major), Ok(minor), Ok(_patch)) = (
2038        major.parse::<u64>(),
2039        minor.parse::<u64>(),
2040        patch.parse::<u64>(),
2041    ) else {
2042        return false;
2043    };
2044    (major, minor) >= (0, 49)
2045}
2046
2047/// A stored [`CardResponseV1`], as a deserializable mirror.
2048///
2049/// [`CardResponseV1`] is `Serialize` only (and its `units` block holds `&'static str`s), so
2050/// a stored response is read back through this twin. It accepts the response document
2051/// unchanged, field for field: `kind` and `units` are load-bearing here — they say what the
2052/// rows ARE and what a row MEANS — while the descriptive scalars are optional so a card
2053/// stored by an older engine still prints.
2054///
2055/// **Deliberately NOT `deny_unknown_fields`** (unlike [`CardRequestV1`], where the caller
2056/// really is the author of the document). What arrives here is this engine's own output,
2057/// round-tripped through an app's storage — never hand-written input — so strictness buys
2058/// no validation and costs a hard cross-version break: the FIRST field ever added to
2059/// `CardResponseV1` would make every card saved by the newer platform completely
2060/// unexportable on the older one. The two apps carry independent engine pins and ship
2061/// through separate stores, so that staggering is the normal case, not a downgrade. An
2062/// unknown field is therefore ignored, exactly as the apps' own decoders ignore it, and the
2063/// stored cells still reach the paper. The same reasoning applies to
2064/// [`StoredCardUnitsBlockV1`] and [`StoredCardRowV1`] below.
2065#[cfg(feature = "pdf")]
2066#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
2067pub struct StoredCardResponseV1 {
2068    /// Which card this is. Anything but [`PDF_CARD_KIND`] is refused.
2069    pub kind: String,
2070    /// Column labels and the range unit of the stored rows. The printed headings are
2071    /// these, not the request's, because these are what the rows were computed in.
2072    pub units: StoredCardUnitsBlockV1,
2073    /// The rows to print, in order.
2074    pub rows: Vec<StoredCardRowV1>,
2075    #[serde(default)]
2076    pub schema_version: Option<u32>,
2077    #[serde(default)]
2078    pub zero_distance: Option<f64>,
2079    /// The scalar BC the stored rows were computed with; printed in the footer when the card
2080    /// carries no `bc_ladder`. Absent (a card saved by an engine before
2081    /// [`CardResponseV1::bc_for_solve`] existed) falls back to the request's published
2082    /// `ballistic_coefficient`. Under a ladder it is only the interior-gap fallback, and the
2083    /// footer prints the ladder's range instead (MBA-1601).
2084    #[serde(default)]
2085    pub bc_for_solve: Option<f64>,
2086    /// The [`CardResponseV1::bc_ladder`] block, round-tripped (MBA-1601): present when the
2087    /// stored rows flew a BC ladder, and then the footer's `BC:` states the ladder's range.
2088    ///
2089    /// A card saved before the block existed has none, and reprints its stored scalar exactly
2090    /// as before: the reprint never re-derives a ladder from the request, so a solve and a
2091    /// reprint of the same document cannot be labelled differently. Regenerating such a card
2092    /// prints the range.
2093    #[serde(default)]
2094    pub bc_ladder: Option<StoredCardBcLadderV1>,
2095    /// Present only on a wind card, and therefore a refusal here.
2096    #[serde(default)]
2097    pub wind_speeds: Vec<f64>,
2098    #[serde(default)]
2099    pub wind_angles_deg: Vec<f64>,
2100    #[serde(default)]
2101    pub extra_angle_rows: Vec<Vec<StoredCardRowV1>>,
2102    /// Present only when the stored card itself stopped short of the rows its request asked
2103    /// for — the [`CardResponseV1::truncation`] block, round-tripped (MBA-1477).
2104    ///
2105    /// Without this the reprint path loses the notice: `StoredCardResponseV1` ignores unknown
2106    /// fields by design, so a saved card whose rows stopped short came back through here as an
2107    /// unremarkable short card and printed with nothing on the paper saying rows were missing.
2108    /// The screen it was saved from said so; the paper made from that very document did not.
2109    ///
2110    /// A card saved before this field existed simply has no block, which is indistinguishable
2111    /// from an untruncated card and is the best that can be said of it.
2112    #[serde(default)]
2113    pub truncation: Option<StoredCardTruncationV1>,
2114}
2115
2116/// The truncation block of a stored response (see [`CardTruncationV1`], which this mirrors).
2117///
2118/// All three numbers are REQUIRED, unlike the descriptive scalars elsewhere in a stored card:
2119/// they are printed together as one sentence, and a block missing one of them would print
2120/// "TRUNCATED at 0 yd" on a field card. A card that was not truncated omits the whole block,
2121/// which is the only shape that means "nothing to say".
2122#[cfg(feature = "pdf")]
2123#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
2124pub struct StoredCardTruncationV1 {
2125    pub requested_end: f64,
2126    pub last_row: f64,
2127    pub reach: f64,
2128}
2129
2130/// The `bc_ladder` block of a stored response (see [`CardBcLadderV1`], which this mirrors).
2131/// Unknown fields are ignored, for the reason [`StoredCardResponseV1`] gives.
2132///
2133/// `segments` and `interior_gap` are REQUIRED, like the numbers of a stored truncation block:
2134/// the footer is printed from them together, and a block missing either cannot say which BCs
2135/// flew. A card that flew no ladder omits the whole block.
2136#[cfg(feature = "pdf")]
2137#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
2138pub struct StoredCardBcLadderV1 {
2139    pub segments: Vec<StoredCardBcSegmentV1>,
2140    pub interior_gap: bool,
2141    /// `"bc_segments"` or `"bc5d_table"`; informational, not printed.
2142    #[serde(default)]
2143    pub source: Option<String>,
2144}
2145
2146/// One band of a stored `bc_ladder` (see [`CardBcSegmentV1`], which this mirrors). Unknown
2147/// fields are ignored, for the reason [`StoredCardResponseV1`] gives.
2148#[cfg(feature = "pdf")]
2149#[derive(Debug, Clone, Copy, Default, PartialEq, Deserialize)]
2150pub struct StoredCardBcSegmentV1 {
2151    pub velocity_min: f64,
2152    pub velocity_max: f64,
2153    pub bc: f64,
2154}
2155
2156#[cfg(feature = "pdf")]
2157impl From<StoredCardTruncationV1> for CardTruncationV1 {
2158    fn from(stored: StoredCardTruncationV1) -> Self {
2159        Self {
2160            requested_end: stored.requested_end,
2161            last_row: stored.last_row,
2162            reach: stored.reach,
2163        }
2164    }
2165}
2166
2167/// The `units` block of a stored response (see [`CardUnitsBlockV1`], which this mirrors).
2168/// Unknown fields are ignored, for the reason [`StoredCardResponseV1`] gives.
2169#[cfg(feature = "pdf")]
2170#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
2171pub struct StoredCardUnitsBlockV1 {
2172    /// `"yd"` / `"m"` — the unit the stored `range` values are in.
2173    pub distance: String,
2174    /// Drop column label ("MIL"/"MOA"/"SMOA"/"IPHY"/"CLICKS").
2175    pub elevation_adjustment: String,
2176    /// Wind and Lead column label; may differ from the elevation label (MBA-1410).
2177    pub windage_adjustment: String,
2178    #[serde(default)]
2179    pub velocity: Option<String>,
2180    #[serde(default)]
2181    pub energy: Option<String>,
2182    #[serde(default)]
2183    pub drop: Option<String>,
2184    #[serde(default)]
2185    pub wind_speed: Option<String>,
2186}
2187
2188/// One stored row (see [`CardRowV1`], which this mirrors). Only `range` is required; a
2189/// column the stored card did not carry stays `None` and prints as an em-dash rather than a
2190/// fabricated zero. Unknown fields are ignored, for the reason [`StoredCardResponseV1`]
2191/// gives.
2192#[cfg(feature = "pdf")]
2193#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
2194pub struct StoredCardRowV1 {
2195    pub range: f64,
2196    #[serde(default)]
2197    pub drop_linear: Option<f64>,
2198    #[serde(default)]
2199    pub drop_adj: Option<f64>,
2200    #[serde(default)]
2201    pub come_up: Option<f64>,
2202    #[serde(default)]
2203    pub wind_linear: Option<f64>,
2204    #[serde(default)]
2205    pub wind_adj: Option<f64>,
2206    #[serde(default)]
2207    pub velocity: Option<f64>,
2208    #[serde(default)]
2209    pub energy: Option<f64>,
2210    /// Time of flight, seconds. The Lead column of a reprint is derived from this — pure
2211    /// arithmetic on a number the stored card already carries, not a new trajectory. A row
2212    /// without it prints an em-dash for Lead.
2213    #[serde(default)]
2214    pub time: Option<f64>,
2215    #[serde(default)]
2216    pub wind_columns: Vec<f64>,
2217}
2218
2219/// A rendered PDF dope card: the document plus the facts a transport needs to describe it
2220/// without re-parsing the bytes.
2221#[cfg(feature = "pdf")]
2222#[derive(Debug, Clone)]
2223pub struct PdfCardV1 {
2224    pub pdf_bytes: Vec<u8>,
2225    /// Pages in `pdf_bytes`, from [`crate::pdf_dope_card::dope_card_page_count`] — the
2226    /// same function the generator paginated by, not a second estimate of it.
2227    pub page_count: usize,
2228    /// Rows printed. On the solve path this is the on-screen `card.range_table` row count;
2229    /// on the reprint path it is the stored row count, so a caller can confirm every stored
2230    /// row reached the paper.
2231    pub row_count: usize,
2232    /// Solved here, or printed from caller-supplied rows.
2233    pub source: PdfRowSource,
2234    /// Characters of the header title (`pdf.title`) the card's font could not draw —
2235    /// distinct, in order of first use, and empty when the title printed in full.
2236    ///
2237    /// Not an error: the document is complete and every ROW printed. But a title is how a
2238    /// shooter tells one card from another, and Liberation Sans covers no CJK, Arabic,
2239    /// Hebrew, Thai or emoji, so a name in any of those used to vanish from the paper with
2240    /// `ok: true` and nothing said. Each such character prints as
2241    /// [`crate::pdf_dope_card::UNPRINTABLE_SUBSTITUTE`] and is named here, so a caller that
2242    /// accepts any non-empty card name (both apps do) can warn at export time instead of
2243    /// handing over an unidentifiable card.
2244    pub unprintable_title_chars: String,
2245    /// Present ONLY when the printed rows stop short of the card that was asked for — the
2246    /// same block [`CardResponseV1::truncation`] carries, for the same reason (MBA-1477).
2247    ///
2248    /// Both paths fill it: a SOLVE takes the truncation of the very `card.range_table` rows
2249    /// it printed, and a REPRINT takes the one the stored card was saved with. Until this
2250    /// existed the PDF was the only card surface with no truncation signal at all — not the
2251    /// stderr warning a CLI reader gets, not the `truncated` block in CLI JSON, not
2252    /// `CardResponseV1::truncation` on the bridge — so a caller could not even tell that the
2253    /// document it was about to hand a shooter was short. It is also printed in the
2254    /// document's own footer, which is the half that survives being carried to a range.
2255    pub truncation: Option<CardTruncationV1>,
2256}
2257
2258/// Resolve the effective table font scale from the mutually exclusive `font_scale` /
2259/// `font_preset` options.
2260#[cfg(feature = "pdf")]
2261fn resolve_font_scale(opts: &PdfCardOptionsV1) -> Result<f32, CardServiceError> {
2262    use crate::pdf_dope_card::{FontSizePreset, FONT_SCALE_RANGE};
2263
2264    match (opts.font_scale, opts.font_preset.as_deref()) {
2265        (Some(_), Some(_)) => Err(CardServiceError::InvalidRequest(
2266            "pdf.font_scale and pdf.font_preset are mutually exclusive; supply one".into(),
2267        )),
2268        (Some(scale), None) => {
2269            let scale = scale as f32;
2270            if !scale.is_finite() || !FONT_SCALE_RANGE.contains(&scale) {
2271                return Err(CardServiceError::InvalidRequest(format!(
2272                    "pdf.font_scale must be finite and within {}..={}, got {}",
2273                    FONT_SCALE_RANGE.start(),
2274                    FONT_SCALE_RANGE.end(),
2275                    opts.font_scale.unwrap_or(f64::NAN)
2276                )));
2277            }
2278            Ok(scale)
2279        }
2280        (None, Some(preset)) => FontSizePreset::from_str(preset)
2281            .map(|p| p.scale())
2282            .ok_or_else(|| {
2283                CardServiceError::InvalidRequest(format!(
2284                    "pdf.font_preset '{preset}' is not one of small, medium, large"
2285                ))
2286            }),
2287        (None, None) => Ok(1.0),
2288    }
2289}
2290
2291/// The rows to print plus everything the header/footer states about where they came from.
2292/// Filled by exactly one of the two paths in [`pdf_card_v1`], which then share one
2293/// renderer, so a reprint and a solve cannot be laid out or labelled differently.
2294#[cfg(feature = "pdf")]
2295struct RowsToPrint {
2296    rows: Vec<crate::card::CardRow>,
2297    source: PdfRowSource,
2298    /// Footer `BC:` — the scalar BC these rows were computed with, printed when `bc_label`
2299    /// is empty.
2300    bc: f64,
2301    /// Footer `BC:` text for a card that flew a BC ladder (its range, from
2302    /// [`crate::pdf_dope_card::ladder_bc_label`]); empty for a card that flew none (MBA-1601).
2303    bc_label: String,
2304    /// Footer `Engine:` — empty prints nothing.
2305    engine_version: String,
2306    /// Footer `Table:` — empty prints nothing.
2307    table_version: String,
2308    /// Drop column label.
2309    elevation_unit_label: String,
2310    /// Wind and Lead column label.
2311    windage_unit_label: String,
2312    /// Footer truncation notice — `None` on a card that reached every row it asked for.
2313    /// Both paths fill it, so the printed notice cannot depend on where the rows came from.
2314    truncation: Option<CardTruncationV1>,
2315}
2316
2317/// Turn a stored response's rows into printable rows, deriving only the Lead column.
2318///
2319/// The Drop and Wind cells are the stored values, untouched. Lead is `target_speed x stored
2320/// ToF` held on the windage axis — the same [`crate::lead_from_tof`] arithmetic
2321/// `range_table_rows` performs, applied to the time of flight the stored row already
2322/// carries. No trajectory, no zero solve, no correction table.
2323#[cfg(feature = "pdf")]
2324fn stored_rows_to_print(
2325    stored: &StoredCardResponseV1,
2326    req: &CardRequestV1,
2327    r: &Resolved,
2328    target_speed: Option<f64>,
2329) -> Vec<crate::card::CardRow> {
2330    use crate::card::CardRow;
2331
2332    let lead_speed_mps = target_speed.map(|speed| r.u.wind_to_metric(speed));
2333    stored
2334        .rows
2335        .iter()
2336        .map(|row| CardRow {
2337            range: row.range,
2338            drop_linear: row.drop_linear,
2339            drop_adj: row.drop_adj,
2340            come_up: row.come_up,
2341            wind_linear: row.wind_linear,
2342            wind_adj: row.wind_adj,
2343            velocity: row.velocity,
2344            energy: row.energy,
2345            time: row.time,
2346            lead_adj: lead_speed_mps.zip(row.time).map(|(speed_mps, tof_s)| {
2347                let range_m = r.u.distance_to_metric(row.range);
2348                let lead_display = r.u.distance_from_metric(
2349                    crate::lead_from_tof(speed_mps, 90.0, tof_s, range_m).lead_m,
2350                );
2351                // Bias-free (it composes on top of the wind dial, which carries the
2352                // zero-set bias) but still divided by the windage tracking CF — the exact
2353                // treatment `range_table_rows` and the CLI's Lead column give it.
2354                windage_adjustment_display(
2355                    lead_display,
2356                    row.range,
2357                    r.windage_unit,
2358                    r.windage_click,
2359                    0.0,
2360                    req.windage_cf,
2361                )
2362                .value
2363            }),
2364            wind_columns: row.wind_columns.clone(),
2365        })
2366        .collect()
2367}
2368
2369/// Reject a stored card that is not the range table it must be, cell values included.
2370#[cfg(feature = "pdf")]
2371fn validate_stored_card(
2372    stored: &StoredCardV1,
2373    req: &CardRequestV1,
2374    r: &Resolved,
2375) -> Result<(), CardServiceError> {
2376    let card = &stored.card;
2377    if card.kind != PDF_CARD_KIND {
2378        return Err(CardServiceError::InvalidRequest(format!(
2379            "card.pdf prints a {PDF_CARD_KIND} card; the stored card's kind is '{}'. Its \
2380             columns are not a range table's, so it is refused rather than reprinted as one",
2381            card.kind
2382        )));
2383    }
2384    // The same "this is not a range table" test the REQUEST gets (see the `wind_speeds` /
2385    // `wind_angles_deg` loop in `pdf_card_v1`), field for field. The two halves used to
2386    // disagree — `wind_angles_deg` was refused on the request and accepted here — and the
2387    // stored half is the one that has to hold when a future kind starts emitting it.
2388    for (field, present) in [
2389        ("wind_speeds", !card.wind_speeds.is_empty()),
2390        ("wind_angles_deg", !card.wind_angles_deg.is_empty()),
2391        ("extra_angle_rows", !card.extra_angle_rows.is_empty()),
2392    ] {
2393        if present {
2394            return Err(CardServiceError::InvalidRequest(format!(
2395                "the stored card carries {field}, a wind card's defining field, so it is not the \
2396                 {PDF_CARD_KIND} response it claims to be"
2397            )));
2398        }
2399    }
2400    if card.rows.is_empty() {
2401        return Err(CardServiceError::InvalidRequest(
2402            "the stored card has no rows; an empty dope card is not a document".into(),
2403        ));
2404    }
2405
2406    // A row means something only in a unit. If the stored labels and the request's own axes
2407    // disagree, the two are not the same card — printing anyway would put one unit's numbers
2408    // under another unit's heading, which is precisely the failure the shared request shape
2409    // exists to prevent.
2410    let elevation = adjustment_unit_label(req.adjustment_unit);
2411    let windage = adjustment_unit_label(r.windage_unit);
2412    let distance = r.u.distance_label();
2413    for (field, stored_label, request_label) in [
2414        ("units.distance", card.units.distance.as_str(), distance),
2415        (
2416            "units.elevation_adjustment",
2417            card.units.elevation_adjustment.as_str(),
2418            elevation.as_str(),
2419        ),
2420        (
2421            "units.windage_adjustment",
2422            card.units.windage_adjustment.as_str(),
2423            windage.as_str(),
2424        ),
2425    ] {
2426        if stored_label != request_label {
2427            return Err(CardServiceError::InvalidRequest(format!(
2428                "the stored card's {field} is '{stored_label}' but this request's own axes say \
2429                 '{request_label}' — the stored rows and the request are not the same card"
2430            )));
2431        }
2432    }
2433
2434    // Same rule as the cells below: a non-finite figure would print "NaN"/"inf" — here in a
2435    // sentence telling a shooter where their card stops, which is worse than in a column.
2436    if let Some(t) = &card.truncation {
2437        for (field, value) in [
2438            ("requested_end", t.requested_end),
2439            ("last_row", t.last_row),
2440            ("reach", t.reach),
2441        ] {
2442            if !value.is_finite() {
2443                return Err(CardServiceError::InvalidRequest(format!(
2444                    "the stored card's truncation.{field} is not a finite number"
2445                )));
2446            }
2447        }
2448    }
2449
2450    // The footer prints the ladder's BC range from this block, so a block with nothing to
2451    // print, or a BC no ladder could fly, is refused rather than printed (MBA-1601).
2452    if let Some(ladder) = &card.bc_ladder {
2453        if ladder.segments.is_empty() {
2454            return Err(CardServiceError::InvalidRequest(
2455                "the stored card's bc_ladder has no segments".into(),
2456            ));
2457        }
2458        for (index, segment) in ladder.segments.iter().enumerate() {
2459            if !(segment.bc.is_finite() && segment.bc > 0.0) {
2460                return Err(CardServiceError::InvalidRequest(format!(
2461                    "the stored card's bc_ladder.segments[{index}].bc is not a finite positive \
2462                     number"
2463                )));
2464            }
2465        }
2466    }
2467
2468    // A non-finite cell would print "NaN"/"inf" on a field card.
2469    for (index, row) in card.rows.iter().enumerate() {
2470        for (field, value) in [
2471            ("range", Some(row.range)),
2472            ("drop_adj", row.drop_adj),
2473            ("wind_adj", row.wind_adj),
2474            ("time", row.time),
2475        ] {
2476            if value.is_some_and(|v| !v.is_finite()) {
2477                return Err(CardServiceError::InvalidRequest(format!(
2478                    "the stored card's rows[{index}].{field} is not a finite number"
2479                )));
2480            }
2481        }
2482    }
2483    Ok(())
2484}
2485
2486/// Render the printable PDF dope card for a card request.
2487///
2488/// `stored` decides where the numbers come from, and it is the whole point of this surface:
2489///
2490/// * `Some(card)` — REPRINT. The rows are the caller's: the stored `card.range_table`
2491///   response for this same request. Nothing is solved, `bc5d_table_path` is never opened,
2492///   and the footer's BC, engine version and table version are the stored card's. A saved
2493///   card's ROWS therefore reprint identically after an engine bump, after the correction
2494///   table at the stored path is overwritten in place, and even after that file is deleted.
2495///   The header is not stored: it states the air the stored request resolves to on this
2496///   engine (MBA-1593), so a card saved before 0.49.0 at altitude without a pressure or
2497///   temperature reprints with the corrected header over its unchanged rows.
2498/// * `None` — SOLVE. `range_table_rows` runs, i.e. literally the rows [`range_table_v1`]
2499///   would return for this request, and the footer states THIS build's version. Unchanged
2500///   behaviour for a caller that has no stored rows.
2501///
2502/// Either way this prints a [`PDF_CARD_KIND`] card and nothing else: a request carrying a
2503/// wind card's `wind_speeds`/`wind_angles_deg`, or a stored card of another kind, is refused.
2504/// Both paths map their rows onto the CLI's Range/Drop/Wind/Lead dope card, plus the Lead
2505/// column that `CardRequestV1::pdf`'s `target_speed` asks for. The Range column is
2506/// denominated in the stored/requested `distance_unit`, unlike `trajectory -o pdf`, whose
2507/// dope card is always yards.
2508///
2509/// The header/footer block is always imperial, matching both CLI PDF call sites: whatever the
2510/// request's velocity/temperature/pressure/altitude/wind/weight dimensions are denominated in,
2511/// they are converted for display only. The `Solver:` label reports this build (`online` with
2512/// the `online` feature, otherwise `offline`) and the timestamp is generation time, so neither
2513/// is caller-settable — and neither is a number a shooter dials.
2514#[cfg(feature = "pdf")]
2515pub fn pdf_card_v1(
2516    req: &CardRequestV1,
2517    stored: Option<&StoredCardV1>,
2518) -> Result<PdfCardV1, CardServiceError> {
2519    use crate::atmosphere::{resolve_station_conditions_with_presence, PressureReferenceMode};
2520    use crate::pdf_dope_card::{
2521        dope_card_page_count, generate_dope_card_pdf, ladder_bc_label, DopeCardConfig,
2522        HeaderAtmosphere, RangeUnit,
2523    };
2524
2525    let opts = req.pdf.clone().unwrap_or_default();
2526    let drag_model = DragModel::from(req.drag_model).to_string();
2527    // Validate presentation options BEFORE spending a zero solve and two trajectories on a
2528    // request that cannot be rendered anyway.
2529    let font_scale = resolve_font_scale(&opts)?;
2530    // A non-finite crossing speed would print "inf"/"NaN" in every Lead cell rather than
2531    // failing. (A NEGATIVE speed is accepted, matching the CLI: it reads as the mover
2532    // crossing the other way and simply flips the sign of the hold.)
2533    if opts.target_speed.is_some_and(|speed| !speed.is_finite()) {
2534        return Err(CardServiceError::InvalidRequest(
2535            "pdf.target_speed must be finite".into(),
2536        ));
2537    }
2538
2539    // A wind card's defining fields cannot be honoured by a range-table PDF. Refuse them
2540    // instead of returning a document whose Wind column contradicts the screen.
2541    for (field, present) in [
2542        ("wind_speeds", !req.wind_speeds.is_empty()),
2543        ("wind_angles_deg", !req.wind_angles_deg.is_empty()),
2544    ] {
2545        if present {
2546            return Err(CardServiceError::InvalidRequest(format!(
2547                "card.pdf prints a {PDF_CARD_KIND} card; this request carries {field}, a wind \
2548                 card's defining field, which a range-table PDF cannot show and would silently \
2549                 ignore. There is no wind_card or come_ups PDF in this build"
2550            )));
2551        }
2552    }
2553
2554    let to_print = match stored {
2555        Some(stored) => {
2556            // Axes and click graduations only: no BC schedule, so no correction table is
2557            // opened for a card whose numbers are already decided.
2558            let r = resolve_axes_only(req)?;
2559            validate_stored_card(stored, req, &r)?;
2560            let bc = stored
2561                .card
2562                .bc_for_solve
2563                .unwrap_or(req.ballistic_coefficient);
2564            RowsToPrint {
2565                rows: stored_rows_to_print(&stored.card, req, &r, opts.target_speed),
2566                source: PdfRowSource::StoredRows,
2567                bc,
2568                // Only the stored card's own ladder block: a card saved without one reprints
2569                // its scalar as before, never a ladder re-derived from the request.
2570                bc_label: stored
2571                    .card
2572                    .bc_ladder
2573                    .as_ref()
2574                    .map(|ladder| {
2575                        let bcs: Vec<f64> = ladder.segments.iter().map(|s| s.bc).collect();
2576                        ladder_bc_label(&drag_model, &bcs, bc, ladder.interior_gap)
2577                    })
2578                    .unwrap_or_default(),
2579                engine_version: stored.engine_version.clone().unwrap_or_default(),
2580                table_version: stored.bc5d_table_version.clone().unwrap_or_default(),
2581                elevation_unit_label: stored.card.units.elevation_adjustment.clone(),
2582                windage_unit_label: stored.card.units.windage_adjustment.clone(),
2583                // The truncation the card was SAVED with. Nothing is re-derived from the
2584                // stored row set here — a reprint prints the card it was handed, and that
2585                // includes what the card already said about itself.
2586                truncation: stored.card.truncation.map(CardTruncationV1::from),
2587            }
2588        }
2589        None => {
2590            let (card, lead_adj, bc_for_solve) = range_table_rows(req, opts.target_speed)?;
2591            if card.rows.is_empty() {
2592                // `generate_dope_card_pdf` refuses an empty row set, and rightly: an empty
2593                // dope card is not a document. Name the cause the caller can act on instead.
2594                return Err(CardServiceError::InvalidRequest(format!(
2595                    "no card rows in {}..={} — the trajectory does not reach this range domain, \
2596                     or step is coarser than the samples",
2597                    req.start, req.end
2598                )));
2599            }
2600            RowsToPrint {
2601                rows: card
2602                    .rows
2603                    .iter()
2604                    .zip(&lead_adj)
2605                    .map(|(row, lead)| crate::card::CardRow {
2606                        range: row.range,
2607                        drop_linear: row.drop_linear,
2608                        drop_adj: row.drop_adj,
2609                        come_up: row.come_up,
2610                        wind_linear: row.wind_linear,
2611                        wind_adj: row.wind_adj,
2612                        velocity: row.velocity,
2613                        energy: row.energy,
2614                        time: row.time,
2615                        lead_adj: *lead,
2616                        wind_columns: row.wind_columns.clone(),
2617                    })
2618                    .collect(),
2619                source: PdfRowSource::Solve,
2620                bc: bc_for_solve,
2621                // The ladder `card.range_table` reports for these very rows.
2622                bc_label: card
2623                    .bc_ladder
2624                    .as_ref()
2625                    .map(|ladder| {
2626                        let bcs: Vec<f64> = ladder.segments.iter().map(|s| s.bc).collect();
2627                        ladder_bc_label(&drag_model, &bcs, bc_for_solve, ladder.interior_gap)
2628                    })
2629                    .unwrap_or_default(),
2630                // No table version is knowable from a path, so none is claimed; the engine
2631                // version is this build's, which is what produced these rows.
2632                engine_version: env!("CARGO_PKG_VERSION").to_string(),
2633                table_version: String::new(),
2634                // The labels `card.range_table`'s `units` block reports for this request:
2635                // Drop in the elevation unit, Wind AND Lead in the (possibly different,
2636                // MBA-1410) windage unit.
2637                elevation_unit_label: card.units.elevation_adjustment.clone(),
2638                windage_unit_label: card.units.windage_adjustment.clone(),
2639                // The truncation of the very rows above, not a second determination of it:
2640                // `range_table_rows` IS `card.range_table`, so the paper and the screen
2641                // truncate identically or not at all.
2642                truncation: card.truncation,
2643            }
2644        }
2645    };
2646
2647    // Refuse on the row/page count now that the rows are known — before a document is built.
2648    // The byte cap downstream can only measure a document it already paid for.
2649    let page_count = dope_card_page_count(to_print.rows.len(), font_scale);
2650    if to_print.rows.len() > MAX_PDF_ROWS || page_count > MAX_PDF_PAGES {
2651        return Err(CardServiceError::TooLarge(format!(
2652            "this card has too many rows to print: {} rows, {page_count} pages (the limits are \
2653             {MAX_PDF_ROWS} rows and {MAX_PDF_PAGES} pages)",
2654            to_print.rows.len()
2655        )));
2656    }
2657
2658    // The request's own axes, for the header. Resolved here rather than taken from above
2659    // because neither branch hands a `Resolved` back — the reprint's is scoped to its arm and
2660    // the solve's lives inside `range_table_rows`. Resolving twice is safe precisely because
2661    // `Units::resolve` is a pure function of the request: the header cannot land on axes the
2662    // rows were not computed in.
2663    let u = Units::resolve(req);
2664    // The atmosphere defaults `resolve()` applies, restated for the header so the printed
2665    // conditions are the ones the solve used rather than blanks.
2666    let temperature = req.temperature.unwrap_or_else(|| u.default_temperature());
2667    let pressure = req.pressure.unwrap_or_else(|| u.default_pressure());
2668    // NOTE: `CardRequestV1::altitude` defaults to METRES whatever the `units` preset says —
2669    // the one dimension whose fallback is not the preset's, because it never was (see
2670    // `CardRequestV1::altitude_unit`). The header reports the altitude the solve actually
2671    // used, so it converts out of whatever `altitude_unit` settled on.
2672    let altitude_ft = u.altitude_to_feet(req.altitude);
2673    // MBA-1593: and the AIR the solve actually flew. `resolve()` hands the solver this
2674    // (°C, hPa) pair, which it resolves as an absolute station reading (a card request has no
2675    // QNH mode): a sea-level default at a real altitude becomes ICAO air there. The header
2676    // used to print the pair as handed, so a 1500 m card with no pressure read DA:0 ft. The
2677    // stated figures use the CLI dope card's own 33.8639 inHg <-> hPa factor (main.rs uses it
2678    // on both PDF call sites), NOT pdf_dope_card::INHG_TO_HPA, matching the shipped header.
2679    //
2680    // 0.49.0: a field the request carried is a reading and the solve flies it as typed, so the
2681    // solve path's header resolves with the request's presence, exactly as the rows were
2682    // solved. A reprint's rows were solved by whichever engine produced them, so its header
2683    // takes presence only when those rows are from 0.49.0 or later, and otherwise states the
2684    // pre-0.49.0 air they flew (see `StoredCardV1::engine_version`).
2685    let supplied = (
2686        u.temperature_to_metric(temperature),
2687        u.pressure_to_metric(pressure),
2688    );
2689    let request_presence =
2690        StationAtmospherePresence::new(req.temperature.is_some(), req.pressure.is_some());
2691    let header_presence = match stored {
2692        Some(stored) if !stored_rows_are_presence_aware(&stored.engine_version) => {
2693            StationAtmospherePresence::NEITHER
2694        }
2695        _ => request_presence,
2696    };
2697    let flown = resolve_station_conditions_with_presence(
2698        supplied.0,
2699        supplied.1,
2700        u.altitude_to_metric(req.altitude),
2701        PressureReferenceMode::Absolute,
2702        header_presence,
2703    );
2704    let header_air = HeaderAtmosphere::new(
2705        u.temperature_to_fahrenheit(temperature),
2706        u.pressure_to_inhg(pressure),
2707        u.pressure_to_hpa(pressure),
2708    )
2709    .as_flown(supplied, flown);
2710
2711    let config = DopeCardConfig {
2712        rifle_name: opts
2713            .title
2714            .clone()
2715            .unwrap_or_else(|| "Dope Card".to_string()),
2716        location: opts.location.clone().unwrap_or_default(),
2717        density_altitude_ft: header_air.density_altitude_ft,
2718        pressure_inhg: header_air.pressure_inhg,
2719        pressure_hpa: header_air.pressure_hpa,
2720        temperature_f: header_air.temperature_f,
2721        altitude_ft,
2722        wind_speed_mph: u.wind_to_mph(req.wind_speed),
2723        // Absent target speed prints 0 here (there is no lead to state) while the Lead
2724        // column itself stays em-dashed; an explicit 0.0 prints the same 0 and zeroes.
2725        target_speed_mph: match opts.target_speed {
2726            Some(speed) => u.wind_to_mph(speed),
2727            None => 0.0,
2728        },
2729        solver_mode: if cfg!(feature = "online") {
2730            "online".to_string()
2731        } else {
2732            "offline".to_string()
2733        },
2734        powder: opts.powder.clone().unwrap_or_default(),
2735        bullet: opts.bullet.clone().unwrap_or_default(),
2736        weight_gr: u.mass_to_grains_cli(req.mass),
2737        // The BC these rows came from: the stored card's own, or this solve's (which is the
2738        // muzzle-corrected value when a BC5D table applied one). Under a BC ladder the footer
2739        // prints `bc_label`, the ladder's range, instead (MBA-1601).
2740        bc: to_print.bc,
2741        drag_model,
2742        bc_label: to_print.bc_label.clone(),
2743        velocity_fps: u.velocity_to_fps_pdf(req.muzzle_velocity),
2744        font_scale,
2745        bold_data: opts.bold_data,
2746        // The card's own axes: Drop in the elevation unit, Wind AND Lead in the (possibly
2747        // different, MBA-1410) windage unit — from the document that owns the rows.
2748        elevation_unit_label: to_print.elevation_unit_label.clone(),
2749        windage_unit_label: to_print.windage_unit_label.clone(),
2750        // Provenance on the paper, so a printed card and a screen can be reconciled later.
2751        engine_version: to_print.engine_version.clone(),
2752        table_version: to_print.table_version.clone(),
2753        // MBA-1477: and the truncation on the paper too, in the one wording every card
2754        // surface uses, denominated in the card's own distance unit (which
2755        // `validate_stored_card` has already forced to agree with the request's).
2756        truncation_note: to_print
2757            .truncation
2758            .map(|t| CardTruncation::from(t).printed_note(u.distance_label()))
2759            .unwrap_or_default(),
2760    };
2761
2762    let range_unit = match u.distance {
2763        CardDistanceUnit::Yards => RangeUnit::Yards,
2764        CardDistanceUnit::Meters => RangeUnit::Meters,
2765    };
2766    // Read BEFORE the document is built, off the same string the header will draw, so the
2767    // report describes this card's own title rather than a truncation of it.
2768    let unprintable_title_chars = crate::pdf_dope_card::unprintable_chars(&config.rifle_name);
2769    let pdf_bytes = generate_dope_card_pdf(&config, &to_print.rows, range_unit)
2770        .map_err(|e| CardServiceError::Pdf(e.to_string()))?;
2771
2772    Ok(PdfCardV1 {
2773        page_count,
2774        row_count: to_print.rows.len(),
2775        source: to_print.source,
2776        pdf_bytes,
2777        unprintable_title_chars,
2778        truncation: to_print.truncation,
2779    })
2780}
2781
2782#[cfg(all(test, feature = "pdf"))]
2783mod stored_engine_version_tests {
2784    use super::stored_rows_are_presence_aware;
2785
2786    #[test]
2787    fn only_a_readable_version_of_0_49_or_later_is_presence_aware() {
2788        let aware = |v: Option<&str>| stored_rows_are_presence_aware(&v.map(str::to_string));
2789        for version in [
2790            "0.49.0",
2791            "v0.49.1-rc1",
2792            "0.49.0+build.7",
2793            "0.50.0",
2794            "0.100.3",
2795            "1.0.0",
2796        ] {
2797            assert!(aware(Some(version)), "{version}");
2798        }
2799        for version in [
2800            "0.48.0", "0.48.9", "0.9.99", "", "unknown", "0.49", "0.49.x", "x0.49.0",
2801        ] {
2802            assert!(!aware(Some(version)), "{version}");
2803        }
2804        assert!(!aware(None));
2805    }
2806}