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