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