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}