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}