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 declared
12//! `units` system, exactly like the CLI flags they mirror: imperial = fps, grains,
13//! inches, yards, °F, inHg, mph; metric = m/s, grams, mm, meters, °C, hPa, m/s.
14//! A DOPE card is a display artifact; its inputs and outputs share the shooter's
15//! unit world, and this keeps the request shape identical to the documented CLI
16//! surface.
17
18use serde::{Deserialize, Serialize};
19
20use crate::adjustment::{
21    adjustment_display, adjustment_unit_label, parse_click_value, windage_adjustment_display,
22    AdjustmentUnit, ClickValue,
23};
24use crate::hold_curve::{
25    card_sample_max_range_m, range_not_sampled_message, run_sampled_flight, sample_at_range,
26    CardTruncation, SampledFlight, CARD_SAMPLE_INTERVAL_M,
27};
28use crate::{AtmosphericConditions, BallisticInputs, BCSegmentData, DragModel, WindConditions};
29
30/// Schema version of the card request/response contract.
31pub const CARD_SCHEMA_VERSION_V1: u32 = 1;
32
33const GRAINS_TO_KG: f64 = crate::constants::GRAINS_TO_KG;
34
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum CardUnits {
38    #[default]
39    Imperial,
40    Metric,
41}
42
43/// Everything the three card surfaces share: load, zero, atmosphere, display axes.
44/// Field values are in the `units` system (see module docs).
45#[derive(Debug, Clone, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct CardRequestV1 {
48    #[serde(default)]
49    pub units: CardUnits,
50
51    // Load
52    pub muzzle_velocity: f64,
53    pub ballistic_coefficient: f64,
54    /// grains (imperial) / grams (metric)
55    pub mass: f64,
56    /// inches (imperial) / mm (metric)
57    pub diameter: f64,
58    #[serde(default)]
59    pub drag_model: DragModelV1,
60    /// inches (imperial) / mm (metric); CLI default 1.5 in
61    #[serde(default = "default_sight_height")]
62    pub sight_height: f64,
63
64    // Zero
65    pub zero_distance: f64,
66    /// Deliberate POI offset at the zero range, in the units system's LINEAR drop
67    /// unit (inches / mm); 0 = zeroed dead-on.
68    #[serde(default)]
69    pub zero_poi_vertical: f64,
70    #[serde(default)]
71    pub zero_poi_horizontal: f64,
72    /// Lateral sight-to-bore mount offset (inches / mm).
73    #[serde(default)]
74    pub sight_offset_lateral: f64,
75
76    // Atmosphere (CLI defaults: 59 °F / 15 °C, 29.92 inHg / 1013.25 hPa, 50 %, 0 alt)
77    #[serde(default)]
78    pub temperature: Option<f64>,
79    #[serde(default)]
80    pub pressure: Option<f64>,
81    #[serde(default = "default_humidity")]
82    pub humidity: f64,
83    #[serde(default)]
84    pub altitude: f64,
85
86    // Wind for the flight (mph / m/s; wind-FROM degrees). The wind card ignores
87    // these and sweeps its own speed list.
88    #[serde(default)]
89    pub wind_speed: f64,
90    #[serde(default)]
91    pub wind_direction_deg: f64,
92
93    // Card domain (yards / meters)
94    pub start: f64,
95    pub end: f64,
96    pub step: f64,
97
98    // Display axes
99    #[serde(default)]
100    pub adjustment_unit: AdjustmentUnit,
101    /// Windage axis unit; defaults to the elevation axis.
102    #[serde(default)]
103    pub windage_unit: Option<AdjustmentUnit>,
104    /// Turret graduations as suffixed strings ("0.1mil", "0.25moa"); required when
105    /// the corresponding axis unit is `clicks`. Windage falls back to elevation.
106    #[serde(default)]
107    pub elevation_click_value: Option<String>,
108    #[serde(default)]
109    pub windage_click_value: Option<String>,
110    /// Scope tracking correction factors (MBA-1358); 1.0 = tracks true.
111    #[serde(default = "default_cf")]
112    pub elevation_cf: f64,
113    #[serde(default = "default_cf")]
114    pub windage_cf: f64,
115    /// Selected zero set's dial corrections, true angular MIL (MBA-1360).
116    #[serde(default)]
117    pub zero_set_elevation_bias_mil: f64,
118    #[serde(default)]
119    pub zero_set_windage_bias_mil: f64,
120
121    /// Wind card only: the sweep of wind speeds (mph / m/s), one output column each.
122    #[serde(default)]
123    pub wind_speeds: Vec<f64>,
124    /// Wind card only: wind-FROM angles in degrees; default `[90]` (full-value from
125    /// the right), matching the CLI.
126    #[serde(default)]
127    pub wind_angles_deg: Vec<f64>,
128
129    /// Explicit velocity-banded BC schedule, mirroring the CLI's repeatable
130    /// `--bc-segment VMIN:VMAX:BC`. Velocities are in the request's `units` velocity
131    /// unit (fps imperial / m/s metric), exactly like the CLI flag. When supplied it
132    /// wins over `bc5d_table_path`, and — CLI parity — the scalar
133    /// `ballistic_coefficient` remains the interior-gap fallback unchanged. The
134    /// schedule feeds BOTH the zero solve and every sampled trajectory.
135    #[serde(default)]
136    pub bc_segments: Option<Vec<CardBcSegmentV1>>,
137    /// Filesystem path to a caliber-specific BC5D correction table
138    /// (`bc5d_<caliber>.bin`, the exact format the CLI's `--bc-table-dir`
139    /// consumes). The table is CRC-verified, a velocity-keyed BC schedule is
140    /// generated for this load (same ladder as the CLI), and the scalar BC gets the
141    /// table's muzzle correction as the interior-gap fallback. Ignored when
142    /// `bc_segments` is supplied. Not available on wasm32 builds (no filesystem);
143    /// there it is rejected with an invalid-request error.
144    #[serde(default)]
145    pub bc5d_table_path: Option<String>,
146
147    /// Presentation-only options for the printable PDF dope card (`card.pdf`). Nothing in
148    /// here touches the ballistics: the numbers in the PDF are the rows
149    /// [`range_table_v1`] returns, computed by the same call.
150    ///
151    /// It lives on the shared request — rather than in a `card.pdf`-only wrapper type — so
152    /// an app stores ONE `CardRequestV1` per saved card and replays that exact document
153    /// against either surface: `card.range_table` for the screen, `card.pdf` for the
154    /// printout. The on-screen commands ignore this block.
155    ///
156    /// Deliberately NOT `#[cfg(feature = "pdf")]`, even though everything that consumes it
157    /// is: `CardRequestV1` denies unknown fields, so gating the field would make a
158    /// pdf-less build REJECT a stored request that carries it, breaking exactly the
159    /// round-trip the field exists to support. A pdf-less build parses it and never reads
160    /// it; only the `card.pdf` command itself disappears.
161    #[serde(default)]
162    pub pdf: Option<PdfCardOptionsV1>,
163}
164
165/// Presentation knobs for the PDF dope card: the header/footer labels the printed card
166/// carries and the table's font size. The ballistics axes it prints (elevation unit,
167/// windage unit, click graduations, tracking CFs, zero-set biases) are NOT here — they are
168/// already fields on [`CardRequestV1`], shared with the on-screen card, precisely so the
169/// two cannot disagree about what a row means.
170///
171/// Every field is optional and defaults to the CLI dope card's own default.
172#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
173#[serde(deny_unknown_fields)]
174pub struct PdfCardOptionsV1 {
175    /// Leading text of header line 1 — the CLI's rifle/profile name. Default
176    /// `"Dope Card"`. Long headers are truncated to fit the page.
177    #[serde(default)]
178    pub title: Option<String>,
179    /// Header `Loc:` label. Default empty.
180    #[serde(default)]
181    pub location: Option<String>,
182    /// Footer `Powder:` label. Default empty.
183    #[serde(default)]
184    pub powder: Option<String>,
185    /// Footer `Bullet:` label. Default empty.
186    #[serde(default)]
187    pub bullet: Option<String>,
188    /// Crossing-target speed for the Lead column, in the request's `units` speed unit
189    /// (mph imperial / m/s metric) — the same convention as the CLI's `--target-speed`
190    /// (MBA-1325). The lead is `speed * time-of-flight` for a full-value 90° crossing,
191    /// held on the windage axis, exactly as `trajectory -o pdf` computes it.
192    ///
193    /// Absent means "this card carries no lead data": the Lead column renders as em-dashes
194    /// rather than a column of confident-looking zeroes. An explicit `0.0` is a different
195    /// statement — a stationary target, whose lead genuinely is zero — and prints zeroes,
196    /// matching the CLI's `--target-speed 0` default.
197    #[serde(default)]
198    pub target_speed: Option<f64>,
199    /// Data-table font scale. Must be finite and within
200    /// [`crate::pdf_dope_card::FONT_SCALE_RANGE`] (0.5..=3.0); out-of-band values are
201    /// rejected rather than silently clamped, so what a stored request asks for is what it
202    /// gets. Mutually exclusive with `font_preset`. Default 1.0.
203    #[serde(default)]
204    pub font_scale: Option<f64>,
205    /// Named font scale: `"small"` (0.8), `"medium"` (1.0), `"large"` (1.4) — the CLI's
206    /// `--font-preset` vocabulary, single-letter aliases included. An unrecognized preset
207    /// is an error here (the CLI warns and falls back to medium; a stored request should
208    /// not silently render at a size it did not ask for). Mutually exclusive with
209    /// `font_scale`.
210    #[serde(default)]
211    pub font_preset: Option<String>,
212    /// Render the data rows in bold. Default false.
213    #[serde(default)]
214    pub bold_data: bool,
215}
216
217/// One velocity band of an explicit velocity-keyed BC schedule
218/// (`CardRequestV1::bc_segments`). `velocity_min`/`velocity_max` are in the
219/// request's `units` velocity unit and must satisfy `velocity_min < velocity_max`;
220/// `bc` is the BC (for the request's `drag_model`) that applies inside the band.
221#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
222#[serde(deny_unknown_fields)]
223pub struct CardBcSegmentV1 {
224    pub velocity_min: f64,
225    pub velocity_max: f64,
226    pub bc: f64,
227}
228
229fn default_sight_height() -> f64 {
230    // The service can't know units at field-default time; resolved in `resolve()`.
231    f64::NAN
232}
233fn default_humidity() -> f64 {
234    50.0
235}
236fn default_cf() -> f64 {
237    1.0
238}
239
240/// Drag model selector mirroring the CLI's `--drag-model` strings.
241#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(rename_all = "lowercase")]
243pub enum DragModelV1 {
244    G1,
245    #[default]
246    G7,
247    G2,
248    G5,
249    G6,
250    G8,
251    GI,
252    GS,
253    RA4,
254}
255
256impl From<DragModelV1> for DragModel {
257    fn from(v: DragModelV1) -> Self {
258        match v {
259            DragModelV1::G1 => DragModel::G1,
260            DragModelV1::G7 => DragModel::G7,
261            DragModelV1::G2 => DragModel::G2,
262            DragModelV1::G5 => DragModel::G5,
263            DragModelV1::G6 => DragModel::G6,
264            DragModelV1::G8 => DragModel::G8,
265            DragModelV1::GI => DragModel::GI,
266            DragModelV1::GS => DragModel::GS,
267            DragModelV1::RA4 => DragModel::RA4,
268        }
269    }
270}
271
272#[derive(Debug, Clone, Serialize)]
273pub struct CardRowV1 {
274    /// Range in the request's distance unit (yd / m).
275    pub range: f64,
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub drop_linear: Option<f64>,
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub drop_adj: Option<f64>,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub come_up: Option<f64>,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub wind_linear: Option<f64>,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub wind_adj: Option<f64>,
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub velocity: Option<f64>,
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub energy: Option<f64>,
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub time: Option<f64>,
292    #[serde(skip_serializing_if = "Vec::is_empty", default)]
293    pub wind_columns: Vec<f64>,
294}
295
296#[derive(Debug, Clone, Serialize)]
297pub struct CardUnitsBlockV1 {
298    pub distance: &'static str,
299    pub velocity: &'static str,
300    pub energy: &'static str,
301    pub drop: &'static str,
302    pub wind_speed: &'static str,
303    pub elevation_adjustment: String,
304    pub windage_adjustment: String,
305}
306
307#[derive(Debug, Clone, Serialize)]
308pub struct CardResponseV1 {
309    pub schema_version: u32,
310    pub kind: &'static str,
311    pub zero_distance: f64,
312    /// The scalar BC these rows were actually computed with: the request's published
313    /// `ballistic_coefficient` unless a `bc5d_table_path` applied its muzzle correction, in
314    /// which case it is the corrected value (the same scalar the solve and every sampled
315    /// flight ran with).
316    ///
317    /// A saved card must be able to state the BC its numbers came from — the printed
318    /// footer's `BC:` — long after the correction table it used has been replaced on disk.
319    /// Without this on the response there is nothing for an app to store, and a reprint
320    /// could only quote the BC the request nominated.
321    pub bc_for_solve: f64,
322    pub units: CardUnitsBlockV1,
323    /// Wind card only: the swept speeds, one per `wind_columns` entry.
324    #[serde(skip_serializing_if = "Vec::is_empty", default)]
325    pub wind_speeds: Vec<f64>,
326    /// Wind card only: one block of rows per wind angle.
327    #[serde(skip_serializing_if = "Vec::is_empty", default)]
328    pub wind_angles_deg: Vec<f64>,
329    pub rows: Vec<CardRowV1>,
330    /// Wind card with multiple angles: rows for angles beyond the first.
331    #[serde(skip_serializing_if = "Vec::is_empty", default)]
332    pub extra_angle_rows: Vec<Vec<CardRowV1>>,
333    /// Present ONLY when the solved flight stopped short of the rows requested, in which case
334    /// `rows` holds every row it did reach and nothing past them (MBA-1476 follow-up).
335    ///
336    /// Additive and absent on every card that ran to its requested `end`, so a caller reading
337    /// an untruncated card sees byte-identical JSON to before. It is a field rather than only
338    /// a log line because the apps and the Flask layer consume this shape: a card that quietly
339    /// returns fewer rows than were asked for, with nothing on the response saying so, is the
340    /// same class of silent failure as the substituted row this work removed.
341    #[serde(skip_serializing_if = "Option::is_none", default)]
342    pub truncation: Option<CardTruncationV1>,
343}
344
345/// The wire form of [`CardTruncation`]: the rows a card could not print and why.
346/// Distances are in the response's own `units.distance`.
347#[derive(Debug, Clone, Copy, Serialize)]
348pub struct CardTruncationV1 {
349    /// The furthest row the request asked for.
350    pub requested_end: f64,
351    /// The last row actually present in `rows`.
352    pub last_row: f64,
353    /// How far the solved flight got — a property of the load, not of the request.
354    pub reach: f64,
355}
356
357impl CardTruncationV1 {
358    fn new(truncation: CardTruncation) -> Self {
359        Self {
360            requested_end: truncation.requested_end,
361            last_row: truncation.last_row,
362            reach: truncation.reach,
363        }
364    }
365}
366
367/// Back to the engine type, so the wording lives on [`CardTruncation`] alone and no surface
368/// has to restate the field mapping in order to say the same sentence.
369impl From<CardTruncationV1> for CardTruncation {
370    fn from(wire: CardTruncationV1) -> Self {
371        Self {
372            requested_end: wire.requested_end,
373            last_row: wire.last_row,
374            reach: wire.reach,
375        }
376    }
377}
378
379#[derive(Debug, thiserror::Error)]
380pub enum CardServiceError {
381    #[error("invalid request: {0}")]
382    InvalidRequest(String),
383    #[error("zero solve failed: {0}")]
384    ZeroSolve(String),
385    #[error("trajectory failed: {0}")]
386    Trajectory(String),
387    /// PDF rendering itself failed (font load/parse, or an empty row set). Gated with the
388    /// only code path that can produce it, so a build without `pdf` sees an unchanged enum.
389    #[cfg(feature = "pdf")]
390    #[error("pdf generation failed: {0}")]
391    Pdf(String),
392    /// The card is too big to print ([`MAX_PDF_ROWS`] / [`MAX_PDF_PAGES`]). A resource
393    /// refusal, not a failure: the request was well formed and the caller can be told
394    /// exactly what is true of the card (how many rows, how many pages), so the message is
395    /// carried verbatim rather than prefixed.
396    #[cfg(feature = "pdf")]
397    #[error("{0}")]
398    TooLarge(String),
399}
400
401// --- Unit conversions: byte-identical constants to the CLI's UnitConverter ---
402
403struct Units {
404    imperial: bool,
405}
406
407impl Units {
408    fn velocity_to_metric(&self, v: f64) -> f64 {
409        if self.imperial { v * 0.3048 } else { v }
410    }
411    fn mass_to_kg(&self, v: f64) -> f64 {
412        if self.imperial { v * GRAINS_TO_KG } else { v * 0.001 }
413    }
414    fn distance_to_metric(&self, v: f64) -> f64 {
415        if self.imperial { v * 0.9144 } else { v }
416    }
417    fn small_len_to_metric(&self, v: f64) -> f64 {
418        if self.imperial { v * 0.0254 } else { v * 0.001 }
419    }
420    fn wind_to_metric(&self, v: f64) -> f64 {
421        if self.imperial { v * 0.44704 } else { v }
422    }
423    fn temperature_to_metric(&self, v: f64) -> f64 {
424        if self.imperial { (v - 32.0) * 5.0 / 9.0 } else { v }
425    }
426    fn pressure_to_metric(&self, v: f64) -> f64 {
427        if self.imperial { v * 33.8639 } else { v }
428    }
429    fn velocity_from_metric(&self, v: f64) -> f64 {
430        if self.imperial { v / 0.3048 } else { v }
431    }
432    fn distance_from_metric(&self, v: f64) -> f64 {
433        if self.imperial { v / 0.9144 } else { v }
434    }
435    fn energy_from_metric(&self, v: f64) -> f64 {
436        if self.imperial { v * 0.737562 } else { v }
437    }
438    fn drop_linear_from_metric(&self, v: f64) -> f64 {
439        if self.imperial { v / 0.0254 } else { v * 1000.0 }
440    }
441    /// Label for the distance axis, for error messages that must name a range.
442    fn distance_label(&self) -> &'static str {
443        if self.imperial { "yd" } else { "m" }
444    }
445}
446
447struct Resolved {
448    u: Units,
449    velocity_m: f64,
450    mass_kg: f64,
451    diameter_m: f64,
452    sight_height_m: f64,
453    zero_distance_m: f64,
454    zero_poi_vertical_m: f64,
455    zero_poi_horizontal_m: f64,
456    sight_offset_lateral_m: f64,
457    temperature_c: f64,
458    pressure_hpa: f64,
459    end_m: f64,
460    elevation_click: Option<ClickValue>,
461    windage_click: Option<ClickValue>,
462    windage_unit: AdjustmentUnit,
463    drag_model: DragModel,
464    /// Scalar BC actually fed to the zero solve and every sampled run. Equals the
465    /// request's `ballistic_coefficient` unless a BC5D table applied its muzzle
466    /// correction (CLI parity: the scalar is the schedule's interior-gap fallback).
467    bc_for_solve: f64,
468    /// Velocity-keyed BC schedule (velocities in fps, the unit the solver compares
469    /// against), from explicit `bc_segments` or generated from `bc5d_table_path`.
470    bc_segments_fps: Option<Vec<BCSegmentData>>,
471}
472
473fn resolve(req: &CardRequestV1) -> Result<Resolved, CardServiceError> {
474    resolve_inner(req, true)
475}
476
477/// [`resolve`] WITHOUT the BC-schedule step — the request's declared axes, click
478/// graduations and unit conversions, and nothing that touches the filesystem.
479///
480/// This is the resolve `card.pdf` performs when it prints caller-supplied rows: those rows
481/// were computed elsewhere (they are the stored `card.range_table` response), so opening
482/// `bc5d_table_path` here would do nothing but couple a reprint to whether that file is
483/// still on the device — which is exactly the coupling printing stored rows exists to
484/// break. `bc_for_solve` is left at the request's published BC and is not used for the
485/// footer on that path (the stored card's own `bc_for_solve` is).
486#[cfg(feature = "pdf")]
487fn resolve_axes_only(req: &CardRequestV1) -> Result<Resolved, CardServiceError> {
488    resolve_inner(req, false)
489}
490
491fn resolve_inner(req: &CardRequestV1, load_bc_schedule: bool) -> Result<Resolved, CardServiceError> {
492    let imperial = req.units == CardUnits::Imperial;
493    let u = Units { imperial };
494
495    for (name, v) in [
496        ("muzzle_velocity", req.muzzle_velocity),
497        ("ballistic_coefficient", req.ballistic_coefficient),
498        ("mass", req.mass),
499        ("diameter", req.diameter),
500        ("zero_distance", req.zero_distance),
501        ("start", req.start),
502        ("end", req.end),
503        ("step", req.step),
504    ] {
505        if !v.is_finite() || v <= 0.0 {
506            return Err(CardServiceError::InvalidRequest(format!(
507                "{name} must be finite and positive, got {v}"
508            )));
509        }
510    }
511    if req.end < req.start {
512        return Err(CardServiceError::InvalidRequest(
513            "end must be >= start".into(),
514        ));
515    }
516    if !(0.5..=1.5).contains(&req.elevation_cf) || !(0.5..=1.5).contains(&req.windage_cf) {
517        return Err(CardServiceError::InvalidRequest(
518            "tracking correction factors must lie in (0.5, 1.5)".into(),
519        ));
520    }
521
522    let sight_height = if req.sight_height.is_nan() {
523        if imperial {
524            1.5
525        } else {
526            38.0
527        }
528    } else {
529        req.sight_height
530    };
531    let temperature = req.temperature.unwrap_or(if imperial { 59.0 } else { 15.0 });
532    let pressure = req.pressure.unwrap_or(if imperial { 29.92 } else { 1013.25 });
533
534    let parse_click = |label: &str, s: &Option<String>| -> Result<Option<ClickValue>, CardServiceError> {
535        s.as_deref()
536            .map(|raw| {
537                parse_click_value(raw).map_err(|e| {
538                    CardServiceError::InvalidRequest(format!("{label}: {e}"))
539                })
540            })
541            .transpose()
542    };
543    let elevation_click = parse_click("elevation_click_value", &req.elevation_click_value)?;
544    let windage_click =
545        parse_click("windage_click_value", &req.windage_click_value)?.or(elevation_click);
546
547    if req.adjustment_unit == AdjustmentUnit::Clicks && elevation_click.is_none() {
548        return Err(CardServiceError::InvalidRequest(
549            "adjustment_unit 'clicks' requires elevation_click_value".into(),
550        ));
551    }
552    let windage_unit = req.windage_unit.unwrap_or(req.adjustment_unit);
553    if windage_unit == AdjustmentUnit::Clicks && windage_click.is_none() {
554        return Err(CardServiceError::InvalidRequest(
555            "windage_unit 'clicks' requires a click graduation".into(),
556        ));
557    }
558
559    let (bc_for_solve, bc_segments_fps) = if load_bc_schedule {
560        resolve_bc_schedule(req, imperial)?
561    } else {
562        (req.ballistic_coefficient, None)
563    };
564
565    Ok(Resolved {
566        bc_for_solve,
567        bc_segments_fps,
568        velocity_m: u.velocity_to_metric(req.muzzle_velocity),
569        mass_kg: u.mass_to_kg(req.mass),
570        diameter_m: u.small_len_to_metric(req.diameter),
571        sight_height_m: u.small_len_to_metric(sight_height),
572        zero_distance_m: u.distance_to_metric(req.zero_distance),
573        zero_poi_vertical_m: u.small_len_to_metric(req.zero_poi_vertical),
574        zero_poi_horizontal_m: u.small_len_to_metric(req.zero_poi_horizontal),
575        sight_offset_lateral_m: u.small_len_to_metric(req.sight_offset_lateral),
576        temperature_c: u.temperature_to_metric(temperature),
577        pressure_hpa: u.pressure_to_metric(pressure),
578        end_m: u.distance_to_metric(req.end),
579        elevation_click,
580        windage_click,
581        windage_unit,
582        drag_model: req.drag_model.into(),
583        u,
584    })
585}
586
587/// Resolve the velocity-keyed BC schedule for a card request, mirroring the CLI's
588/// precedence exactly: explicit `bc_segments` win outright (and leave the scalar BC
589/// untouched, like `--bc-segment` restoring the pre-table BC); otherwise a
590/// `bc5d_table_path` generates the same segment ladder `--bc-table-dir` does and
591/// applies the table's muzzle correction to the scalar fallback BC.
592///
593/// Returns `(scalar BC for the solve, optional fps-keyed schedule)`.
594fn resolve_bc_schedule(
595    req: &CardRequestV1,
596    imperial: bool,
597) -> Result<(f64, Option<Vec<BCSegmentData>>), CardServiceError> {
598    if let Some(segments) = req.bc_segments.as_ref().filter(|s| !s.is_empty()) {
599        // Display velocity -> fps: the exact factor the CLI's parse_bc_segment uses.
600        let to_fps = if imperial { 1.0 } else { 3.280_839_895 };
601        let mut converted = Vec::with_capacity(segments.len());
602        for (index, segment) in segments.iter().enumerate() {
603            if !segment.velocity_min.is_finite()
604                || !segment.velocity_max.is_finite()
605                || !segment.bc.is_finite()
606            {
607                return Err(CardServiceError::InvalidRequest(format!(
608                    "bc_segments[{index}]: velocity_min, velocity_max, and bc must be finite"
609                )));
610            }
611            if segment.velocity_min >= segment.velocity_max {
612                return Err(CardServiceError::InvalidRequest(format!(
613                    "bc_segments[{index}]: velocity_min must be < velocity_max"
614                )));
615            }
616            if segment.bc <= 0.0 {
617                return Err(CardServiceError::InvalidRequest(format!(
618                    "bc_segments[{index}]: bc must be > 0"
619                )));
620            }
621            converted.push(BCSegmentData {
622                velocity_min: segment.velocity_min * to_fps,
623                velocity_max: segment.velocity_max * to_fps,
624                bc_value: segment.bc,
625            });
626        }
627        return Ok((req.ballistic_coefficient, Some(converted)));
628    }
629
630    let Some(path) = req.bc5d_table_path.as_deref() else {
631        return Ok((req.ballistic_coefficient, None));
632    };
633
634    #[cfg(target_arch = "wasm32")]
635    {
636        let _ = path;
637        Err(CardServiceError::InvalidRequest(
638            "bc5d_table_path is not supported on this target (no filesystem); supply \
639             bc_segments instead"
640                .into(),
641        ))
642    }
643    #[cfg(not(target_arch = "wasm32"))]
644    {
645        // The table's CONTENT must be for this shot's caliber, not merely CRC-valid: a
646        // wrong-caliber table silently biases every row of the card (see
647        // Bc5dTable::ensure_caliber_matches), so a mismatch is refused here rather than
648        // applied — and rather than quietly falling back to an uncorrected card, which
649        // the caller could not distinguish from a corrected one.
650        let diameter_in = if imperial { req.diameter } else { req.diameter / 25.4 };
651        let table = crate::bc_table_5d::path_cache::load_verified_for_caliber(
652            std::path::Path::new(path),
653            diameter_in,
654        )
655        .map_err(|e| CardServiceError::InvalidRequest(format!("bc5d_table_path: {e}")))?;
656
657        // The BC5D axes are grains + fps; convert from the request's units the same
658        // way the CLI does. The v2 tables carry only G1/G7 planes — anything else is
659        // typed as G1, matching the CLI/WASM coercion.
660        let weight_grains = if imperial { req.mass } else { req.mass * 15.4324 };
661        let muzzle_fps = if imperial {
662            req.muzzle_velocity
663        } else {
664            req.muzzle_velocity * 3.280_839_895
665        };
666        let drag_type = if req.drag_model == DragModelV1::G7 { "G7" } else { "G1" };
667        let base_bc = req.ballistic_coefficient;
668
669        match table.generate_segments(base_bc, drag_type, weight_grains, Some(muzzle_fps)) {
670            Some(segments) => {
671                // CLI parity: the scalar BC becomes the muzzle-corrected fallback for
672                // interior coverage gaps (main.rs: trued_bc = base * muzzle_correction).
673                let fallback_bc =
674                    table.get_effective_bc(weight_grains, base_bc, muzzle_fps, muzzle_fps, drag_type);
675                Ok((fallback_bc, Some(segments)))
676            }
677            // A neutral table (every sampled cell ~= 1.0) carries no correction:
678            // keep the published scalar BC, exactly like the CLI.
679            None => Ok((base_bc, None)),
680        }
681    }
682}
683
684fn solve_zero(req: &CardRequestV1, r: &Resolved) -> Result<f64, CardServiceError> {
685    let zero_inputs = BallisticInputs {
686        bc_value: r.bc_for_solve,
687        bc_type: r.drag_model,
688        bullet_mass: r.mass_kg,
689        muzzle_velocity: r.velocity_m,
690        bullet_diameter: r.diameter_m,
691        bullet_length: crate::truing::fallback_bullet_length_m(r.diameter_m, r.mass_kg),
692        sight_height: r.sight_height_m,
693        use_rk4: true,
694        zero_poi_vertical_m: r.zero_poi_vertical_m,
695        zero_poi_horizontal_m: r.zero_poi_horizontal_m,
696        sight_offset_lateral_m: r.sight_offset_lateral_m,
697        // The zero MUST be solved with the same velocity-keyed BC schedule the
698        // sampled flights use (the 0.22.11 auto-zero lesson: a schedule that changes
699        // early-flight drag would otherwise mis-zero every row).
700        use_bc_segments: r.bc_segments_fps.is_some(),
701        bc_segments_data: r.bc_segments_fps.clone(),
702        ..Default::default()
703    };
704    let atmosphere = AtmosphericConditions {
705        temperature: r.temperature_c,
706        pressure: r.pressure_hpa,
707        humidity: req.humidity,
708        altitude: req.altitude,
709    };
710    crate::calculate_zero_angle_with_conditions(
711        zero_inputs,
712        r.zero_distance_m,
713        r.sight_height_m,
714        WindConditions::default(),
715        atmosphere,
716    )
717    .map_err(|e| CardServiceError::ZeroSolve(e.to_string()))
718}
719
720#[allow(clippy::too_many_arguments)]
721fn sampled(
722    req: &CardRequestV1,
723    r: &Resolved,
724    wind_speed_m: f64,
725    wind_direction_deg: f64,
726    zero_angle: f64,
727) -> Result<SampledFlight, CardServiceError> {
728    run_sampled_flight(
729        r.velocity_m,
730        r.bc_for_solve,
731        r.mass_kg,
732        r.diameter_m,
733        r.drag_model,
734        r.sight_height_m,
735        r.temperature_c,
736        r.pressure_hpa,
737        req.humidity,
738        req.altitude,
739        wind_speed_m,
740        wind_direction_deg,
741        // MBA-1476: at least one whole grid cell past the last row asked for — see
742        // card_sample_max_range_m for why that headroom decides reachability.
743        card_sample_max_range_m(r.end_m),
744        // MBA-1476: the engine's own card grid, never the request's row spacing.
745        CARD_SAMPLE_INTERVAL_M,
746        zero_angle,
747        // The same schedule as the zero solve above, on every card surface
748        // (come-ups, range table, wind card) consistently.
749        r.bc_segments_fps.clone(),
750        None,
751        None,
752        r.zero_poi_vertical_m,
753        r.zero_poi_horizontal_m,
754        r.sight_offset_lateral_m,
755        Some(r.zero_distance_m),
756    )
757    .map_err(|e| CardServiceError::Trajectory(e.to_string()))
758}
759
760/// Read one card row's sample off a solved flight, or `None` when the flight does not span it
761/// (MBA-1476).
762///
763/// The nearest-sample-within-one-and-a-half-steps search this replaces answered a range the
764/// flight had no sample for with a neighbour's numbers, invisibly. There is no tolerance to
765/// tune here: [`sample_at_range`] interpolates exactly at the requested range whenever the
766/// flight spans it, and a range it cannot span yields nothing at all. The caller ends the card
767/// there and records a [`CardTruncation`] — it never substitutes, and it never drops the row
768/// without saying so.
769fn row_sample(
770    samples: &[crate::trajectory_sampling::TrajectorySample],
771    range_m: f64,
772) -> Option<crate::trajectory_sampling::TrajectorySample> {
773    sample_at_range(samples, range_m)
774}
775
776/// The error a card returns when its flight reaches NONE of the rows requested — there is no
777/// card to truncate, so this stays a refusal (MBA-1476 follow-up).
778fn no_rows_error(first_row_display: f64, reach_m: f64, u: &Units) -> CardServiceError {
779    CardServiceError::Trajectory(range_not_sampled_message(
780        first_row_display,
781        u.distance_from_metric(reach_m),
782        u.distance_label(),
783    ))
784}
785
786/// Record the truncation of a card whose row loop stopped early, in display units.
787fn truncation_of(
788    requested_end: f64,
789    last_row: f64,
790    reach_m: f64,
791    u: &Units,
792) -> Option<CardTruncationV1> {
793    Some(CardTruncationV1::new(CardTruncation {
794        requested_end,
795        last_row,
796        reach: u.distance_from_metric(reach_m),
797    }))
798}
799
800fn units_block(req: &CardRequestV1, windage_unit: AdjustmentUnit) -> CardUnitsBlockV1 {
801    let imperial = req.units == CardUnits::Imperial;
802    CardUnitsBlockV1 {
803        distance: if imperial { "yd" } else { "m" },
804        velocity: if imperial { "fps" } else { "m/s" },
805        energy: if imperial { "ft-lb" } else { "J" },
806        drop: if imperial { "in" } else { "mm" },
807        wind_speed: if imperial { "mph" } else { "m/s" },
808        elevation_adjustment: adjustment_unit_label(req.adjustment_unit),
809        windage_adjustment: adjustment_unit_label(windage_unit),
810    }
811}
812
813/// Come-ups: elevation dial per range plus the incremental come-up between rows.
814/// Mirrors the CLI's `come-ups` command row-for-row.
815pub fn come_ups_v1(req: &CardRequestV1) -> Result<CardResponseV1, CardServiceError> {
816    let r = resolve(req)?;
817    let zero_angle = solve_zero(req, &r)?;
818    let flight = sampled(
819        req,
820        &r,
821        r.u.wind_to_metric(req.wind_speed),
822        req.wind_direction_deg,
823        zero_angle,
824    )?;
825
826    let mut rows: Vec<CardRowV1> = Vec::new();
827    let mut truncation = None;
828    let mut prev_drop_adj: f64 = 0.0;
829    let mut current_range = req.start;
830    while current_range <= req.end + 0.1 {
831        let range_m = r.u.distance_to_metric(current_range);
832        let Some(sample) = row_sample(&flight.samples, range_m) else {
833            let Some(last) = rows.last() else {
834                return Err(no_rows_error(current_range, flight.reach_m, &r.u));
835            };
836            truncation = truncation_of(req.end, last.range, flight.reach_m, &r.u);
837            break;
838        };
839        let drop_yd = r.u.distance_from_metric(sample.drop_m);
840        // MBA-1476: the row's OWN range, not a nearby sample's — the angular conversion
841        // divides by the distance the row is labelled with.
842        let drop_adj = adjustment_display(
843            drop_yd,
844            current_range,
845            req.adjustment_unit,
846            r.elevation_click,
847            req.zero_set_elevation_bias_mil,
848            req.elevation_cf,
849        )
850        .value;
851        rows.push(CardRowV1 {
852            range: current_range,
853            drop_linear: None,
854            drop_adj: Some(drop_adj),
855            come_up: Some(drop_adj - prev_drop_adj),
856            wind_linear: None,
857            wind_adj: None,
858            velocity: Some(r.u.velocity_from_metric(sample.velocity_mps)),
859            energy: Some(r.u.energy_from_metric(sample.energy_j)),
860            time: Some(sample.time_s),
861            wind_columns: Vec::new(),
862        });
863        prev_drop_adj = drop_adj;
864        current_range += req.step;
865    }
866
867    Ok(CardResponseV1 {
868        schema_version: CARD_SCHEMA_VERSION_V1,
869        kind: "come_ups",
870        zero_distance: req.zero_distance,
871        bc_for_solve: r.bc_for_solve,
872        units: units_block(req, req.adjustment_unit),
873        wind_speeds: Vec::new(),
874        wind_angles_deg: Vec::new(),
875        rows,
876        extra_angle_rows: Vec::new(),
877        truncation,
878    })
879}
880
881/// Range table: drop and wind on possibly different display axes.
882/// Mirrors the CLI's `range-table` command row-for-row (two solves: with wind for
883/// drift, without wind for the pure elevation axis).
884pub fn range_table_v1(req: &CardRequestV1) -> Result<CardResponseV1, CardServiceError> {
885    // Discards the PDF-only extras; see `range_table_rows`.
886    range_table_rows(req, None).map(|(card, _lead, _bc)| card)
887}
888
889/// The one range-table computation, shared by the on-screen `card.range_table` and the
890/// printable `card.pdf`.
891///
892/// Returns `(the card, the Lead column, the scalar BC the solve ran with)`:
893///
894/// * The card is exactly what `card.range_table` serializes — `range_table_v1` is a
895///   discarding wrapper, so the PDF's Range/Drop/Wind figures ARE the on-screen figures.
896///   They are taken from here rather than recomputed because recomputing them is the one
897///   way the printed card could ever disagree with the rows the shooter already read.
898/// * The Lead column parallels `card.rows` one-to-one. `lead_target_speed` is in the
899///   request's `units` speed unit; `None` (no lead requested) yields all `None`, which the
900///   PDF renders as em-dashes rather than fake zeroes.
901/// * The BC is `Resolved::bc_for_solve` — the published BC unless a BC5D table applied its
902///   muzzle correction — which the PDF footer prints. The footer must state the BC the
903///   numbers above it came from, not the one the request nominated.
904fn range_table_rows(
905    req: &CardRequestV1,
906    lead_target_speed: Option<f64>,
907) -> Result<(CardResponseV1, Vec<Option<f64>>, f64), CardServiceError> {
908    let r = resolve(req)?;
909    let zero_angle = solve_zero(req, &r)?;
910    let wind_flight = sampled(
911        req,
912        &r,
913        r.u.wind_to_metric(req.wind_speed),
914        req.wind_direction_deg,
915        zero_angle,
916    )?;
917    let no_wind_flight = sampled(req, &r, 0.0, 0.0, zero_angle)?;
918    // Both flights feed every row, so the card can only run as far as the shorter of them.
919    let reach_m = wind_flight.reach_m.min(no_wind_flight.reach_m);
920    let lead_speed_mps = lead_target_speed.map(|speed| r.u.wind_to_metric(speed));
921
922    let mut rows: Vec<CardRowV1> = Vec::new();
923    let mut lead_adj = Vec::new();
924    let mut truncation = None;
925    let mut current_range = req.start;
926    while current_range <= req.end + 0.1 {
927        let range_m = r.u.distance_to_metric(current_range);
928        let (Some(nw), Some(w)) = (
929            row_sample(&no_wind_flight.samples, range_m),
930            row_sample(&wind_flight.samples, range_m),
931        ) else {
932            let Some(last) = rows.last() else {
933                return Err(no_rows_error(current_range, reach_m, &r.u));
934            };
935            truncation = truncation_of(req.end, last.range, reach_m, &r.u);
936            break;
937        };
938        // MBA-1476: the row's OWN range on both axes.
939        let range_display = current_range;
940        let drop_yd = r.u.distance_from_metric(nw.drop_m);
941        let drop_adj = adjustment_display(
942            drop_yd,
943            range_display,
944            req.adjustment_unit,
945            r.elevation_click,
946            req.zero_set_elevation_bias_mil,
947            req.elevation_cf,
948        )
949        .value;
950        let drift_yd = r.u.distance_from_metric(w.wind_drift_m);
951        let wind_adj = windage_adjustment_display(
952            drift_yd,
953            range_display,
954            r.windage_unit,
955            r.windage_click,
956            req.zero_set_windage_bias_mil,
957            req.windage_cf,
958        )
959        .value;
960        // Lead is the windage-axis hold for a full-value 90° crossing target:
961        // `speed * time-of-flight` (MBA-1287's shared moving-target math), on the
962        // no-wind sample whose time this row already reports. It is a COMPONENT
963        // hold composed on top of the wind dial, which already carries the
964        // zero-set bias, so it stays bias-free (bias 0.0) while still being
965        // divided by the windage tracking CF — the same treatment
966        // `main.rs::dope_card_row_from_sample` gives the CLI's Lead column.
967        lead_adj.push(lead_speed_mps.map(|speed_mps| {
968            let lead_display = r.u.distance_from_metric(
969                crate::lead_from_tof(speed_mps, 90.0, nw.time_s, nw.distance_m).lead_m,
970            );
971            windage_adjustment_display(
972                lead_display,
973                range_display,
974                r.windage_unit,
975                r.windage_click,
976                0.0,
977                req.windage_cf,
978            )
979            .value
980        }));
981        rows.push(CardRowV1 {
982            range: current_range,
983            drop_linear: Some(r.u.drop_linear_from_metric(nw.drop_m)),
984            drop_adj: Some(drop_adj),
985            come_up: None,
986            wind_linear: Some(r.u.drop_linear_from_metric(w.wind_drift_m)),
987            wind_adj: Some(wind_adj),
988            velocity: Some(r.u.velocity_from_metric(nw.velocity_mps)),
989            energy: Some(r.u.energy_from_metric(nw.energy_j)),
990            time: Some(nw.time_s),
991            wind_columns: Vec::new(),
992        });
993        current_range += req.step;
994    }
995
996    debug_assert_eq!(rows.len(), lead_adj.len(), "Lead column must parallel rows");
997    Ok((
998        CardResponseV1 {
999            schema_version: CARD_SCHEMA_VERSION_V1,
1000            kind: "range_table",
1001            zero_distance: req.zero_distance,
1002            bc_for_solve: r.bc_for_solve,
1003            units: units_block(req, r.windage_unit),
1004            wind_speeds: Vec::new(),
1005            wind_angles_deg: Vec::new(),
1006            rows,
1007            extra_angle_rows: Vec::new(),
1008            truncation,
1009        },
1010        lead_adj,
1011        r.bc_for_solve,
1012    ))
1013}
1014
1015/// Wind card: drift matrix, ranges x wind speeds, per wind-FROM angle.
1016/// Mirrors the CLI's `wind-card` command cell-for-cell. The windage axis uses
1017/// `adjustment_unit` (the CLI's wind-card has a single unit for the matrix).
1018pub fn wind_card_v1(req: &CardRequestV1) -> Result<CardResponseV1, CardServiceError> {
1019    if req.wind_speeds.is_empty() {
1020        return Err(CardServiceError::InvalidRequest(
1021            "wind_card requires at least one entry in wind_speeds".into(),
1022        ));
1023    }
1024    let r = resolve(req)?;
1025    let zero_angle = solve_zero(req, &r)?;
1026
1027    let angles: Vec<f64> = if req.wind_angles_deg.is_empty() {
1028        vec![90.0]
1029    } else {
1030        req.wind_angles_deg.clone()
1031    };
1032
1033    let mut ranges: Vec<f64> = Vec::new();
1034    let mut current = req.start;
1035    while current <= req.end + 0.1 {
1036        ranges.push(current);
1037        current += req.step;
1038    }
1039
1040    // Every angle/speed combination is its own flight and they do not all reach equally far
1041    // (a crosswind bleeds a little velocity), but the matrix has ONE range axis. So the card
1042    // runs to the shortest of them: `reachable` is the number of leading ranges every flight
1043    // supplies, and the ragged tails are trimmed once, below.
1044    let mut reachable = ranges.len();
1045    let mut reach_m = f64::INFINITY;
1046    let mut per_angle_drifts: Vec<Vec<Vec<f64>>> = Vec::new();
1047    for &angle_deg in &angles {
1048        let mut all_drifts: Vec<Vec<f64>> = vec![Vec::new(); ranges.len()];
1049        for &ws in &req.wind_speeds {
1050            let flight = sampled(req, &r, r.u.wind_to_metric(ws), angle_deg, zero_angle)?;
1051            reach_m = reach_m.min(flight.reach_m);
1052            for (ri, &range_display) in ranges.iter().enumerate().take(reachable) {
1053                let range_m = r.u.distance_to_metric(range_display);
1054                // MBA-1476: a range the flight cannot supply used to become a 0.0 cell —
1055                // indistinguishable from "no drift at all" on a wind card.
1056                let Some(sample) = row_sample(&flight.samples, range_m) else {
1057                    reachable = ri;
1058                    break;
1059                };
1060                let drift_yd = r.u.distance_from_metric(sample.wind_drift_m);
1061                let drift_adj = adjustment_display(
1062                    drift_yd,
1063                    range_display,
1064                    req.adjustment_unit,
1065                    r.windage_click,
1066                    req.zero_set_windage_bias_mil,
1067                    req.windage_cf,
1068                )
1069                .value;
1070                all_drifts[ri].push(drift_adj);
1071            }
1072        }
1073        per_angle_drifts.push(all_drifts);
1074    }
1075
1076    let truncation = if reachable < ranges.len() {
1077        if reachable == 0 {
1078            return Err(no_rows_error(ranges[0], reach_m, &r.u));
1079        }
1080        let last = ranges[reachable - 1];
1081        ranges.truncate(reachable);
1082        truncation_of(req.end, last, reach_m, &r.u)
1083    } else {
1084        None
1085    };
1086
1087    let mut per_angle_rows: Vec<Vec<CardRowV1>> = per_angle_drifts
1088        .into_iter()
1089        .map(|all_drifts| {
1090            ranges
1091                .iter()
1092                .zip(all_drifts)
1093                .map(|(&range, wind_columns)| CardRowV1 {
1094                    range,
1095                    drop_linear: None,
1096                    drop_adj: None,
1097                    come_up: None,
1098                    wind_linear: None,
1099                    wind_adj: None,
1100                    velocity: None,
1101                    energy: None,
1102                    time: None,
1103                    wind_columns,
1104                })
1105                .collect()
1106        })
1107        .collect();
1108
1109    let first = per_angle_rows.remove(0);
1110    Ok(CardResponseV1 {
1111        schema_version: CARD_SCHEMA_VERSION_V1,
1112        kind: "wind_card",
1113        zero_distance: req.zero_distance,
1114        bc_for_solve: r.bc_for_solve,
1115        units: units_block(req, req.adjustment_unit),
1116        wind_speeds: req.wind_speeds.clone(),
1117        wind_angles_deg: angles,
1118        rows: first,
1119        extra_angle_rows: per_angle_rows,
1120        truncation,
1121    })
1122}
1123
1124// ---------------------------------------------------------------------------------------
1125// Printable PDF dope card (`card.pdf`), feature `pdf`.
1126//
1127// Two ways in, one renderer:
1128//
1129// * REPRINT — the caller supplies the rows: `StoredCardV1` carries the stored
1130//   `card.range_table` response verbatim. Nothing is solved, no correction table is opened,
1131//   and the footer's BC and provenance come from that document. Only under this construction
1132//   is a reprint of a saved card actually a reprint: it cannot drift when the engine is
1133//   bumped, or when the BC5D table file at the stored path is overwritten in place by a
1134//   table-set refresh.
1135// * SOLVE — no rows supplied: `range_table_rows` runs, which is the same call
1136//   `card.range_table` makes (`range_table_v1` is a discarding wrapper over it). This is the
1137//   pre-existing behaviour, unchanged, and what the CLI-shaped caller gets.
1138//
1139// Neither path recomputes an adjustment, a unit conversion or a trajectory that the card it
1140// prints already performed.
1141// ---------------------------------------------------------------------------------------
1142
1143/// The one card kind `card.pdf` can print, in the `kind` spelling
1144/// [`CardResponseV1::kind`] uses.
1145///
1146/// A come-ups card's Come-Up column and a wind card's swept drift matrix are not columns a
1147/// range-table card has, so a request for either is REFUSED rather than answered with a
1148/// range table — an `ok` response whose defining field was silently ignored is the defect
1149/// (a stored wind card exported as a range table asserted 0.0 drift on every row while the
1150/// screen showed up to -0.42 MIL).
1151#[cfg(feature = "pdf")]
1152pub const PDF_CARD_KIND: &str = "range_table";
1153
1154/// Rows a printable card may carry.
1155///
1156/// Checked from the row count itself, before a document exists — the byte cap
1157/// (`bridge::MAX_PDF_BYTES`) can only refuse a 26 MB `Vec<u8>` that has already been built
1158/// and paginated. At ~0.5 KiB/row over the ~815 KiB font floor this bound keeps every
1159/// accepted card comfortably inside that cap, which remains the backstop for the other way
1160/// to make a huge document: header/footer labels, which are drawn verbatim on every page.
1161#[cfg(feature = "pdf")]
1162pub const MAX_PDF_ROWS: usize = 5_000;
1163
1164/// Pages a printable card may run to. Bounds the one row set the row cap above cannot:
1165/// few enough rows, but at a font scale that fits very few of them per page.
1166#[cfg(feature = "pdf")]
1167pub const MAX_PDF_PAGES: usize = 60;
1168
1169/// Where the printed rows came from — reported so a caller can verify it got a reprint
1170/// rather than a re-solve, which is otherwise indistinguishable from the document.
1171#[cfg(feature = "pdf")]
1172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1173pub enum PdfRowSource {
1174    /// Solved by this call, exactly as `card.range_table` would for the same request.
1175    Solve,
1176    /// Supplied by the caller: a stored `card.range_table` response, printed as-is.
1177    StoredRows,
1178}
1179
1180#[cfg(feature = "pdf")]
1181impl PdfRowSource {
1182    /// Wire spelling for the bridge's `source` field.
1183    pub fn as_str(self) -> &'static str {
1184        match self {
1185            Self::Solve => "solve",
1186            Self::StoredRows => "stored_rows",
1187        }
1188    }
1189}
1190
1191/// A card whose rows already exist: print THESE numbers instead of solving.
1192///
1193/// `card` is the stored `card.range_table` response, pasted verbatim — an app keeps that
1194/// document for every saved card and shows it on screen, so handing the same bytes back is
1195/// what makes the paper and the screen the same card by construction rather than by
1196/// coincidence. The two provenance strings are printed in the footer so a card in a
1197/// shooter's pocket can be reconciled with a screen afterwards; both are optional, and an
1198/// absent or empty one prints nothing at all rather than a placeholder.
1199#[cfg(feature = "pdf")]
1200#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
1201#[serde(deny_unknown_fields)]
1202pub struct StoredCardV1 {
1203    /// The stored response, exactly as this engine emitted it.
1204    pub card: StoredCardResponseV1,
1205    /// Engine version that produced the rows (`engine_version` from the bridge envelope of
1206    /// the call that produced them). Absent/empty prints no engine line.
1207    #[serde(default)]
1208    pub engine_version: Option<String>,
1209    /// Correction-table version the rows were solved against — the published BC5D table-set
1210    /// version an app records at save time. Absent/empty prints no table line, which is the
1211    /// honest rendering of "this card used no correction table".
1212    #[serde(default)]
1213    pub bc5d_table_version: Option<String>,
1214}
1215
1216/// A stored [`CardResponseV1`], as a deserializable mirror.
1217///
1218/// [`CardResponseV1`] is `Serialize` only (and its `units` block holds `&'static str`s), so
1219/// a stored response is read back through this twin. It accepts the response document
1220/// unchanged, field for field: `kind` and `units` are load-bearing here — they say what the
1221/// rows ARE and what a row MEANS — while the descriptive scalars are optional so a card
1222/// stored by an older engine still prints.
1223///
1224/// **Deliberately NOT `deny_unknown_fields`** (unlike [`CardRequestV1`], where the caller
1225/// really is the author of the document). What arrives here is this engine's own output,
1226/// round-tripped through an app's storage — never hand-written input — so strictness buys
1227/// no validation and costs a hard cross-version break: the FIRST field ever added to
1228/// `CardResponseV1` would make every card saved by the newer platform completely
1229/// unexportable on the older one. The two apps carry independent engine pins and ship
1230/// through separate stores, so that staggering is the normal case, not a downgrade. An
1231/// unknown field is therefore ignored, exactly as the apps' own decoders ignore it, and the
1232/// stored cells still reach the paper. The same reasoning applies to
1233/// [`StoredCardUnitsBlockV1`] and [`StoredCardRowV1`] below.
1234#[cfg(feature = "pdf")]
1235#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
1236pub struct StoredCardResponseV1 {
1237    /// Which card this is. Anything but [`PDF_CARD_KIND`] is refused.
1238    pub kind: String,
1239    /// Column labels and the range unit of the stored rows. The printed headings are
1240    /// these, not the request's, because these are what the rows were computed in.
1241    pub units: StoredCardUnitsBlockV1,
1242    /// The rows to print, in order.
1243    pub rows: Vec<StoredCardRowV1>,
1244    #[serde(default)]
1245    pub schema_version: Option<u32>,
1246    #[serde(default)]
1247    pub zero_distance: Option<f64>,
1248    /// The BC the stored rows were computed with; printed in the footer. Absent (a card
1249    /// saved by an engine before [`CardResponseV1::bc_for_solve`] existed) falls back to the
1250    /// request's published `ballistic_coefficient`.
1251    #[serde(default)]
1252    pub bc_for_solve: Option<f64>,
1253    /// Present only on a wind card, and therefore a refusal here.
1254    #[serde(default)]
1255    pub wind_speeds: Vec<f64>,
1256    #[serde(default)]
1257    pub wind_angles_deg: Vec<f64>,
1258    #[serde(default)]
1259    pub extra_angle_rows: Vec<Vec<StoredCardRowV1>>,
1260    /// Present only when the stored card itself stopped short of the rows its request asked
1261    /// for — the [`CardResponseV1::truncation`] block, round-tripped (MBA-1477).
1262    ///
1263    /// Without this the reprint path loses the notice: `StoredCardResponseV1` ignores unknown
1264    /// fields by design, so a saved card whose rows stopped short came back through here as an
1265    /// unremarkable short card and printed with nothing on the paper saying rows were missing.
1266    /// The screen it was saved from said so; the paper made from that very document did not.
1267    ///
1268    /// A card saved before this field existed simply has no block, which is indistinguishable
1269    /// from an untruncated card and is the best that can be said of it.
1270    #[serde(default)]
1271    pub truncation: Option<StoredCardTruncationV1>,
1272}
1273
1274/// The truncation block of a stored response (see [`CardTruncationV1`], which this mirrors).
1275///
1276/// All three numbers are REQUIRED, unlike the descriptive scalars elsewhere in a stored card:
1277/// they are printed together as one sentence, and a block missing one of them would print
1278/// "TRUNCATED at 0 yd" on a field card. A card that was not truncated omits the whole block,
1279/// which is the only shape that means "nothing to say".
1280#[cfg(feature = "pdf")]
1281#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
1282pub struct StoredCardTruncationV1 {
1283    pub requested_end: f64,
1284    pub last_row: f64,
1285    pub reach: f64,
1286}
1287
1288#[cfg(feature = "pdf")]
1289impl From<StoredCardTruncationV1> for CardTruncationV1 {
1290    fn from(stored: StoredCardTruncationV1) -> Self {
1291        Self {
1292            requested_end: stored.requested_end,
1293            last_row: stored.last_row,
1294            reach: stored.reach,
1295        }
1296    }
1297}
1298
1299/// The `units` block of a stored response (see [`CardUnitsBlockV1`], which this mirrors).
1300/// Unknown fields are ignored, for the reason [`StoredCardResponseV1`] gives.
1301#[cfg(feature = "pdf")]
1302#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
1303pub struct StoredCardUnitsBlockV1 {
1304    /// `"yd"` / `"m"` — the unit the stored `range` values are in.
1305    pub distance: String,
1306    /// Drop column label ("MIL"/"MOA"/"SMOA"/"IPHY"/"CLICKS").
1307    pub elevation_adjustment: String,
1308    /// Wind and Lead column label; may differ from the elevation label (MBA-1410).
1309    pub windage_adjustment: String,
1310    #[serde(default)]
1311    pub velocity: Option<String>,
1312    #[serde(default)]
1313    pub energy: Option<String>,
1314    #[serde(default)]
1315    pub drop: Option<String>,
1316    #[serde(default)]
1317    pub wind_speed: Option<String>,
1318}
1319
1320/// One stored row (see [`CardRowV1`], which this mirrors). Only `range` is required; a
1321/// column the stored card did not carry stays `None` and prints as an em-dash rather than a
1322/// fabricated zero. Unknown fields are ignored, for the reason [`StoredCardResponseV1`]
1323/// gives.
1324#[cfg(feature = "pdf")]
1325#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
1326pub struct StoredCardRowV1 {
1327    pub range: f64,
1328    #[serde(default)]
1329    pub drop_linear: Option<f64>,
1330    #[serde(default)]
1331    pub drop_adj: Option<f64>,
1332    #[serde(default)]
1333    pub come_up: Option<f64>,
1334    #[serde(default)]
1335    pub wind_linear: Option<f64>,
1336    #[serde(default)]
1337    pub wind_adj: Option<f64>,
1338    #[serde(default)]
1339    pub velocity: Option<f64>,
1340    #[serde(default)]
1341    pub energy: Option<f64>,
1342    /// Time of flight, seconds. The Lead column of a reprint is derived from this — pure
1343    /// arithmetic on a number the stored card already carries, not a new trajectory. A row
1344    /// without it prints an em-dash for Lead.
1345    #[serde(default)]
1346    pub time: Option<f64>,
1347    #[serde(default)]
1348    pub wind_columns: Vec<f64>,
1349}
1350
1351/// A rendered PDF dope card: the document plus the facts a transport needs to describe it
1352/// without re-parsing the bytes.
1353#[cfg(feature = "pdf")]
1354#[derive(Debug, Clone)]
1355pub struct PdfCardV1 {
1356    pub pdf_bytes: Vec<u8>,
1357    /// Pages in `pdf_bytes`, from [`crate::pdf_dope_card::dope_card_page_count`] — the
1358    /// same function the generator paginated by, not a second estimate of it.
1359    pub page_count: usize,
1360    /// Rows printed. On the solve path this is the on-screen `card.range_table` row count;
1361    /// on the reprint path it is the stored row count, so a caller can confirm every stored
1362    /// row reached the paper.
1363    pub row_count: usize,
1364    /// Solved here, or printed from caller-supplied rows.
1365    pub source: PdfRowSource,
1366    /// Characters of the header title (`pdf.title`) the card's font could not draw —
1367    /// distinct, in order of first use, and empty when the title printed in full.
1368    ///
1369    /// Not an error: the document is complete and every ROW printed. But a title is how a
1370    /// shooter tells one card from another, and Liberation Sans covers no CJK, Arabic,
1371    /// Hebrew, Thai or emoji, so a name in any of those used to vanish from the paper with
1372    /// `ok: true` and nothing said. Each such character prints as
1373    /// [`crate::pdf_dope_card::UNPRINTABLE_SUBSTITUTE`] and is named here, so a caller that
1374    /// accepts any non-empty card name (both apps do) can warn at export time instead of
1375    /// handing over an unidentifiable card.
1376    pub unprintable_title_chars: String,
1377    /// Present ONLY when the printed rows stop short of the card that was asked for — the
1378    /// same block [`CardResponseV1::truncation`] carries, for the same reason (MBA-1477).
1379    ///
1380    /// Both paths fill it: a SOLVE takes the truncation of the very `card.range_table` rows
1381    /// it printed, and a REPRINT takes the one the stored card was saved with. Until this
1382    /// existed the PDF was the only card surface with no truncation signal at all — not the
1383    /// stderr warning a CLI reader gets, not the `truncated` block in CLI JSON, not
1384    /// `CardResponseV1::truncation` on the bridge — so a caller could not even tell that the
1385    /// document it was about to hand a shooter was short. It is also printed in the
1386    /// document's own footer, which is the half that survives being carried to a range.
1387    pub truncation: Option<CardTruncationV1>,
1388}
1389
1390/// Resolve the effective table font scale from the mutually exclusive `font_scale` /
1391/// `font_preset` options.
1392#[cfg(feature = "pdf")]
1393fn resolve_font_scale(opts: &PdfCardOptionsV1) -> Result<f32, CardServiceError> {
1394    use crate::pdf_dope_card::{FontSizePreset, FONT_SCALE_RANGE};
1395
1396    match (opts.font_scale, opts.font_preset.as_deref()) {
1397        (Some(_), Some(_)) => Err(CardServiceError::InvalidRequest(
1398            "pdf.font_scale and pdf.font_preset are mutually exclusive; supply one".into(),
1399        )),
1400        (Some(scale), None) => {
1401            let scale = scale as f32;
1402            if !scale.is_finite() || !FONT_SCALE_RANGE.contains(&scale) {
1403                return Err(CardServiceError::InvalidRequest(format!(
1404                    "pdf.font_scale must be finite and within {}..={}, got {}",
1405                    FONT_SCALE_RANGE.start(),
1406                    FONT_SCALE_RANGE.end(),
1407                    opts.font_scale.unwrap_or(f64::NAN)
1408                )));
1409            }
1410            Ok(scale)
1411        }
1412        (None, Some(preset)) => FontSizePreset::from_str(preset).map(|p| p.scale()).ok_or_else(|| {
1413            CardServiceError::InvalidRequest(format!(
1414                "pdf.font_preset '{preset}' is not one of small, medium, large"
1415            ))
1416        }),
1417        (None, None) => Ok(1.0),
1418    }
1419}
1420
1421/// The rows to print plus everything the header/footer states about where they came from.
1422/// Filled by exactly one of the two paths in [`pdf_card_v1`], which then share one
1423/// renderer, so a reprint and a solve cannot be laid out or labelled differently.
1424#[cfg(feature = "pdf")]
1425struct RowsToPrint {
1426    rows: Vec<crate::card::CardRow>,
1427    source: PdfRowSource,
1428    /// Footer `BC:` — the BC these rows were computed with.
1429    bc: f64,
1430    /// Footer `Engine:` — empty prints nothing.
1431    engine_version: String,
1432    /// Footer `Table:` — empty prints nothing.
1433    table_version: String,
1434    /// Drop column label.
1435    elevation_unit_label: String,
1436    /// Wind and Lead column label.
1437    windage_unit_label: String,
1438    /// Footer truncation notice — `None` on a card that reached every row it asked for.
1439    /// Both paths fill it, so the printed notice cannot depend on where the rows came from.
1440    truncation: Option<CardTruncationV1>,
1441}
1442
1443/// Turn a stored response's rows into printable rows, deriving only the Lead column.
1444///
1445/// The Drop and Wind cells are the stored values, untouched. Lead is `target_speed x stored
1446/// ToF` held on the windage axis — the same [`crate::lead_from_tof`] arithmetic
1447/// `range_table_rows` performs, applied to the time of flight the stored row already
1448/// carries. No trajectory, no zero solve, no correction table.
1449#[cfg(feature = "pdf")]
1450fn stored_rows_to_print(
1451    stored: &StoredCardResponseV1,
1452    req: &CardRequestV1,
1453    r: &Resolved,
1454    target_speed: Option<f64>,
1455) -> Vec<crate::card::CardRow> {
1456    use crate::card::CardRow;
1457
1458    let lead_speed_mps = target_speed.map(|speed| r.u.wind_to_metric(speed));
1459    stored
1460        .rows
1461        .iter()
1462        .map(|row| CardRow {
1463            range: row.range,
1464            drop_linear: row.drop_linear,
1465            drop_adj: row.drop_adj,
1466            come_up: row.come_up,
1467            wind_linear: row.wind_linear,
1468            wind_adj: row.wind_adj,
1469            velocity: row.velocity,
1470            energy: row.energy,
1471            time: row.time,
1472            lead_adj: lead_speed_mps.zip(row.time).map(|(speed_mps, tof_s)| {
1473                let range_m = r.u.distance_to_metric(row.range);
1474                let lead_display =
1475                    r.u.distance_from_metric(crate::lead_from_tof(speed_mps, 90.0, tof_s, range_m).lead_m);
1476                // Bias-free (it composes on top of the wind dial, which carries the
1477                // zero-set bias) but still divided by the windage tracking CF — the exact
1478                // treatment `range_table_rows` and the CLI's Lead column give it.
1479                windage_adjustment_display(
1480                    lead_display,
1481                    row.range,
1482                    r.windage_unit,
1483                    r.windage_click,
1484                    0.0,
1485                    req.windage_cf,
1486                )
1487                .value
1488            }),
1489            wind_columns: row.wind_columns.clone(),
1490        })
1491        .collect()
1492}
1493
1494/// Reject a stored card that is not the range table it must be, cell values included.
1495#[cfg(feature = "pdf")]
1496fn validate_stored_card(
1497    stored: &StoredCardV1,
1498    req: &CardRequestV1,
1499    r: &Resolved,
1500) -> Result<(), CardServiceError> {
1501    let card = &stored.card;
1502    if card.kind != PDF_CARD_KIND {
1503        return Err(CardServiceError::InvalidRequest(format!(
1504            "card.pdf prints a {PDF_CARD_KIND} card; the stored card's kind is '{}'. Its \
1505             columns are not a range table's, so it is refused rather than reprinted as one",
1506            card.kind
1507        )));
1508    }
1509    // The same "this is not a range table" test the REQUEST gets (see the `wind_speeds` /
1510    // `wind_angles_deg` loop in `pdf_card_v1`), field for field. The two halves used to
1511    // disagree — `wind_angles_deg` was refused on the request and accepted here — and the
1512    // stored half is the one that has to hold when a future kind starts emitting it.
1513    for (field, present) in [
1514        ("wind_speeds", !card.wind_speeds.is_empty()),
1515        ("wind_angles_deg", !card.wind_angles_deg.is_empty()),
1516        ("extra_angle_rows", !card.extra_angle_rows.is_empty()),
1517    ] {
1518        if present {
1519            return Err(CardServiceError::InvalidRequest(format!(
1520                "the stored card carries {field}, a wind card's defining field, so it is not the \
1521                 {PDF_CARD_KIND} response it claims to be"
1522            )));
1523        }
1524    }
1525    if card.rows.is_empty() {
1526        return Err(CardServiceError::InvalidRequest(
1527            "the stored card has no rows; an empty dope card is not a document".into(),
1528        ));
1529    }
1530
1531    // A row means something only in a unit. If the stored labels and the request's own axes
1532    // disagree, the two are not the same card — printing anyway would put one unit's numbers
1533    // under another unit's heading, which is precisely the failure the shared request shape
1534    // exists to prevent.
1535    let elevation = adjustment_unit_label(req.adjustment_unit);
1536    let windage = adjustment_unit_label(r.windage_unit);
1537    let distance = if req.units == CardUnits::Imperial { "yd" } else { "m" };
1538    for (field, stored_label, request_label) in [
1539        ("units.distance", card.units.distance.as_str(), distance),
1540        (
1541            "units.elevation_adjustment",
1542            card.units.elevation_adjustment.as_str(),
1543            elevation.as_str(),
1544        ),
1545        (
1546            "units.windage_adjustment",
1547            card.units.windage_adjustment.as_str(),
1548            windage.as_str(),
1549        ),
1550    ] {
1551        if stored_label != request_label {
1552            return Err(CardServiceError::InvalidRequest(format!(
1553                "the stored card's {field} is '{stored_label}' but this request's own axes say \
1554                 '{request_label}' — the stored rows and the request are not the same card"
1555            )));
1556        }
1557    }
1558
1559    // Same rule as the cells below: a non-finite figure would print "NaN"/"inf" — here in a
1560    // sentence telling a shooter where their card stops, which is worse than in a column.
1561    if let Some(t) = &card.truncation {
1562        for (field, value) in [
1563            ("requested_end", t.requested_end),
1564            ("last_row", t.last_row),
1565            ("reach", t.reach),
1566        ] {
1567            if !value.is_finite() {
1568                return Err(CardServiceError::InvalidRequest(format!(
1569                    "the stored card's truncation.{field} is not a finite number"
1570                )));
1571            }
1572        }
1573    }
1574
1575    // A non-finite cell would print "NaN"/"inf" on a field card.
1576    for (index, row) in card.rows.iter().enumerate() {
1577        for (field, value) in [
1578            ("range", Some(row.range)),
1579            ("drop_adj", row.drop_adj),
1580            ("wind_adj", row.wind_adj),
1581            ("time", row.time),
1582        ] {
1583            if value.is_some_and(|v| !v.is_finite()) {
1584                return Err(CardServiceError::InvalidRequest(format!(
1585                    "the stored card's rows[{index}].{field} is not a finite number"
1586                )));
1587            }
1588        }
1589    }
1590    Ok(())
1591}
1592
1593/// Render the printable PDF dope card for a card request.
1594///
1595/// `stored` decides where the numbers come from, and it is the whole point of this surface:
1596///
1597/// * `Some(card)` — REPRINT. The rows are the caller's: the stored `card.range_table`
1598///   response for this same request. Nothing is solved, `bc5d_table_path` is never opened,
1599///   and the footer's BC, engine version and table version are the stored card's. A saved
1600///   card therefore reprints identically after an engine bump, after the correction table at
1601///   the stored path is overwritten in place, and even after that file is deleted.
1602/// * `None` — SOLVE. `range_table_rows` runs, i.e. literally the rows [`range_table_v1`]
1603///   would return for this request, and the footer states THIS build's version. Unchanged
1604///   behaviour for a caller that has no stored rows.
1605///
1606/// Either way this prints a [`PDF_CARD_KIND`] card and nothing else: a request carrying a
1607/// wind card's `wind_speeds`/`wind_angles_deg`, or a stored card of another kind, is refused.
1608/// Both paths map their rows onto the CLI's Range/Drop/Wind/Lead dope card, plus the Lead
1609/// column that `CardRequestV1::pdf`'s `target_speed` asks for. The Range column is
1610/// denominated in the stored/requested distance unit (yards imperial / metres metric),
1611/// unlike `trajectory -o pdf`, whose dope card is always yards.
1612///
1613/// The header/footer block is always imperial, matching both CLI PDF call sites: a metric
1614/// request's velocity/temperature/pressure/altitude/wind/weight are converted for display
1615/// only. The `Solver:` label reports this build (`online` with the `online` feature,
1616/// otherwise `offline`) and the timestamp is generation time, so neither is caller-settable
1617/// — and neither is a number a shooter dials.
1618#[cfg(feature = "pdf")]
1619pub fn pdf_card_v1(
1620    req: &CardRequestV1,
1621    stored: Option<&StoredCardV1>,
1622) -> Result<PdfCardV1, CardServiceError> {
1623    use crate::pdf_dope_card::{
1624        calculate_density_altitude, dope_card_page_count, generate_dope_card_pdf, DopeCardConfig,
1625        RangeUnit,
1626    };
1627
1628    let opts = req.pdf.clone().unwrap_or_default();
1629    // Validate presentation options BEFORE spending a zero solve and two trajectories on a
1630    // request that cannot be rendered anyway.
1631    let font_scale = resolve_font_scale(&opts)?;
1632    // A non-finite crossing speed would print "inf"/"NaN" in every Lead cell rather than
1633    // failing. (A NEGATIVE speed is accepted, matching the CLI: it reads as the mover
1634    // crossing the other way and simply flips the sign of the hold.)
1635    if opts.target_speed.is_some_and(|speed| !speed.is_finite()) {
1636        return Err(CardServiceError::InvalidRequest(
1637            "pdf.target_speed must be finite".into(),
1638        ));
1639    }
1640
1641    // A wind card's defining fields cannot be honoured by a range-table PDF. Refuse them
1642    // instead of returning a document whose Wind column contradicts the screen.
1643    for (field, present) in [
1644        ("wind_speeds", !req.wind_speeds.is_empty()),
1645        ("wind_angles_deg", !req.wind_angles_deg.is_empty()),
1646    ] {
1647        if present {
1648            return Err(CardServiceError::InvalidRequest(format!(
1649                "card.pdf prints a {PDF_CARD_KIND} card; this request carries {field}, a wind \
1650                 card's defining field, which a range-table PDF cannot show and would silently \
1651                 ignore. There is no wind_card or come_ups PDF in this build"
1652            )));
1653        }
1654    }
1655
1656    let to_print = match stored {
1657        Some(stored) => {
1658            // Axes and click graduations only: no BC schedule, so no correction table is
1659            // opened for a card whose numbers are already decided.
1660            let r = resolve_axes_only(req)?;
1661            validate_stored_card(stored, req, &r)?;
1662            RowsToPrint {
1663                rows: stored_rows_to_print(&stored.card, req, &r, opts.target_speed),
1664                source: PdfRowSource::StoredRows,
1665                bc: stored.card.bc_for_solve.unwrap_or(req.ballistic_coefficient),
1666                engine_version: stored.engine_version.clone().unwrap_or_default(),
1667                table_version: stored.bc5d_table_version.clone().unwrap_or_default(),
1668                elevation_unit_label: stored.card.units.elevation_adjustment.clone(),
1669                windage_unit_label: stored.card.units.windage_adjustment.clone(),
1670                // The truncation the card was SAVED with. Nothing is re-derived from the
1671                // stored row set here — a reprint prints the card it was handed, and that
1672                // includes what the card already said about itself.
1673                truncation: stored.card.truncation.map(CardTruncationV1::from),
1674            }
1675        }
1676        None => {
1677            let (card, lead_adj, bc_for_solve) = range_table_rows(req, opts.target_speed)?;
1678            if card.rows.is_empty() {
1679                // `generate_dope_card_pdf` refuses an empty row set, and rightly: an empty
1680                // dope card is not a document. Name the cause the caller can act on instead.
1681                return Err(CardServiceError::InvalidRequest(format!(
1682                    "no card rows in {}..={} — the trajectory does not reach this range domain, \
1683                     or step is coarser than the samples",
1684                    req.start, req.end
1685                )));
1686            }
1687            RowsToPrint {
1688                rows: card
1689                    .rows
1690                    .iter()
1691                    .zip(&lead_adj)
1692                    .map(|(row, lead)| crate::card::CardRow {
1693                        range: row.range,
1694                        drop_linear: row.drop_linear,
1695                        drop_adj: row.drop_adj,
1696                        come_up: row.come_up,
1697                        wind_linear: row.wind_linear,
1698                        wind_adj: row.wind_adj,
1699                        velocity: row.velocity,
1700                        energy: row.energy,
1701                        time: row.time,
1702                        lead_adj: *lead,
1703                        wind_columns: row.wind_columns.clone(),
1704                    })
1705                    .collect(),
1706                source: PdfRowSource::Solve,
1707                bc: bc_for_solve,
1708                // No table version is knowable from a path, so none is claimed; the engine
1709                // version is this build's, which is what produced these rows.
1710                engine_version: env!("CARGO_PKG_VERSION").to_string(),
1711                table_version: String::new(),
1712                // The labels `card.range_table`'s `units` block reports for this request:
1713                // Drop in the elevation unit, Wind AND Lead in the (possibly different,
1714                // MBA-1410) windage unit.
1715                elevation_unit_label: card.units.elevation_adjustment.clone(),
1716                windage_unit_label: card.units.windage_adjustment.clone(),
1717                // The truncation of the very rows above, not a second determination of it:
1718                // `range_table_rows` IS `card.range_table`, so the paper and the screen
1719                // truncate identically or not at all.
1720                truncation: card.truncation,
1721            }
1722        }
1723    };
1724
1725    // Refuse on the row/page count now that the rows are known — before a document is built.
1726    // The byte cap downstream can only measure a document it already paid for.
1727    let page_count = dope_card_page_count(to_print.rows.len(), font_scale);
1728    if to_print.rows.len() > MAX_PDF_ROWS || page_count > MAX_PDF_PAGES {
1729        return Err(CardServiceError::TooLarge(format!(
1730            "this card has too many rows to print: {} rows, {page_count} pages (the limits are \
1731             {MAX_PDF_ROWS} rows and {MAX_PDF_PAGES} pages)",
1732            to_print.rows.len()
1733        )));
1734    }
1735
1736    let imperial = req.units == CardUnits::Imperial;
1737    // The atmosphere defaults `resolve()` applies, restated for the header so the printed
1738    // conditions are the ones the solve used rather than blanks.
1739    let temperature = req.temperature.unwrap_or(if imperial { 59.0 } else { 15.0 });
1740    let pressure = req.pressure.unwrap_or(if imperial { 29.92 } else { 1013.25 });
1741    // inHg <-> hPa via the CLI dope card's own factor (main.rs uses 33.8639 on both PDF
1742    // call sites), NOT pdf_dope_card::INHG_TO_HPA — matching the shipped header exactly.
1743    let pressure_inhg = if imperial { pressure } else { pressure / 33.8639 };
1744    let pressure_hpa = if imperial { pressure * 33.8639 } else { pressure };
1745    let temperature_f = if imperial { temperature } else { temperature * 9.0 / 5.0 + 32.0 };
1746    // NOTE: `CardRequestV1::altitude` is fed to the solve unconverted (see `solve_zero` /
1747    // `sampled`), i.e. it is METRES in both unit systems — the one field in this request
1748    // that does not follow the module's units convention. The header reports the altitude
1749    // the solve actually used, so it converts from metres regardless of `units`.
1750    let altitude_ft = req.altitude / 0.3048;
1751
1752    let config = DopeCardConfig {
1753        rifle_name: opts.title.clone().unwrap_or_else(|| "Dope Card".to_string()),
1754        location: opts.location.clone().unwrap_or_default(),
1755        density_altitude_ft: calculate_density_altitude(altitude_ft, pressure_inhg, temperature_f),
1756        pressure_inhg,
1757        pressure_hpa,
1758        temperature_f,
1759        altitude_ft,
1760        wind_speed_mph: if imperial { req.wind_speed } else { req.wind_speed / 0.44704 },
1761        // Absent target speed prints 0 here (there is no lead to state) while the Lead
1762        // column itself stays em-dashed; an explicit 0.0 prints the same 0 and zeroes.
1763        target_speed_mph: match opts.target_speed {
1764            Some(speed) if imperial => speed,
1765            Some(speed) => speed / 0.44704,
1766            None => 0.0,
1767        },
1768        solver_mode: if cfg!(feature = "online") { "online".to_string() } else { "offline".to_string() },
1769        powder: opts.powder.clone().unwrap_or_default(),
1770        bullet: opts.bullet.clone().unwrap_or_default(),
1771        weight_gr: if imperial { req.mass } else { req.mass * 15.4324 },
1772        // The BC these rows came from: the stored card's own, or this solve's (which is the
1773        // muzzle-corrected value when a BC5D table applied one).
1774        bc: to_print.bc,
1775        drag_model: DragModel::from(req.drag_model).to_string(),
1776        velocity_fps: if imperial { req.muzzle_velocity } else { req.muzzle_velocity / 0.3048 },
1777        font_scale,
1778        bold_data: opts.bold_data,
1779        // The card's own axes: Drop in the elevation unit, Wind AND Lead in the (possibly
1780        // different, MBA-1410) windage unit — from the document that owns the rows.
1781        elevation_unit_label: to_print.elevation_unit_label.clone(),
1782        windage_unit_label: to_print.windage_unit_label.clone(),
1783        // Provenance on the paper, so a printed card and a screen can be reconciled later.
1784        engine_version: to_print.engine_version.clone(),
1785        table_version: to_print.table_version.clone(),
1786        // MBA-1477: and the truncation on the paper too, in the one wording every card
1787        // surface uses, denominated in the card's own distance unit (which
1788        // `validate_stored_card` has already forced to agree with the request's).
1789        truncation_note: to_print
1790            .truncation
1791            .map(|t| CardTruncation::from(t).printed_note(if imperial { "yd" } else { "m" }))
1792            .unwrap_or_default(),
1793    };
1794
1795    let range_unit = if imperial { RangeUnit::Yards } else { RangeUnit::Meters };
1796    // Read BEFORE the document is built, off the same string the header will draw, so the
1797    // report describes this card's own title rather than a truncation of it.
1798    let unprintable_title_chars = crate::pdf_dope_card::unprintable_chars(&config.rifle_name);
1799    let pdf_bytes = generate_dope_card_pdf(&config, &to_print.rows, range_unit)
1800        .map_err(|e| CardServiceError::Pdf(e.to_string()))?;
1801
1802    Ok(PdfCardV1 {
1803        page_count,
1804        row_count: to_print.rows.len(),
1805        source: to_print.source,
1806        pdf_bytes,
1807        unprintable_title_chars,
1808        truncation: to_print.truncation,
1809    })
1810}