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