use serde::{Deserialize, Serialize};
use crate::adjustment::{
adjustment_display, adjustment_unit_label, parse_click_value, windage_adjustment_display,
AdjustmentUnit, ClickValue,
};
use crate::hold_curve::run_sampled_trajectory;
use crate::{AtmosphericConditions, BallisticInputs, BCSegmentData, DragModel, WindConditions};
pub const CARD_SCHEMA_VERSION_V1: u32 = 1;
const GRAINS_TO_KG: f64 = crate::constants::GRAINS_TO_KG;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CardUnits {
#[default]
Imperial,
Metric,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CardRequestV1 {
#[serde(default)]
pub units: CardUnits,
pub muzzle_velocity: f64,
pub ballistic_coefficient: f64,
pub mass: f64,
pub diameter: f64,
#[serde(default)]
pub drag_model: DragModelV1,
#[serde(default = "default_sight_height")]
pub sight_height: f64,
pub zero_distance: f64,
#[serde(default)]
pub zero_poi_vertical: f64,
#[serde(default)]
pub zero_poi_horizontal: f64,
#[serde(default)]
pub sight_offset_lateral: f64,
#[serde(default)]
pub temperature: Option<f64>,
#[serde(default)]
pub pressure: Option<f64>,
#[serde(default = "default_humidity")]
pub humidity: f64,
#[serde(default)]
pub altitude: f64,
#[serde(default)]
pub wind_speed: f64,
#[serde(default)]
pub wind_direction_deg: f64,
pub start: f64,
pub end: f64,
pub step: f64,
#[serde(default)]
pub adjustment_unit: AdjustmentUnit,
#[serde(default)]
pub windage_unit: Option<AdjustmentUnit>,
#[serde(default)]
pub elevation_click_value: Option<String>,
#[serde(default)]
pub windage_click_value: Option<String>,
#[serde(default = "default_cf")]
pub elevation_cf: f64,
#[serde(default = "default_cf")]
pub windage_cf: f64,
#[serde(default)]
pub zero_set_elevation_bias_mil: f64,
#[serde(default)]
pub zero_set_windage_bias_mil: f64,
#[serde(default)]
pub wind_speeds: Vec<f64>,
#[serde(default)]
pub wind_angles_deg: Vec<f64>,
#[serde(default)]
pub bc_segments: Option<Vec<CardBcSegmentV1>>,
#[serde(default)]
pub bc5d_table_path: Option<String>,
#[serde(default)]
pub pdf: Option<PdfCardOptionsV1>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PdfCardOptionsV1 {
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub powder: Option<String>,
#[serde(default)]
pub bullet: Option<String>,
#[serde(default)]
pub target_speed: Option<f64>,
#[serde(default)]
pub font_scale: Option<f64>,
#[serde(default)]
pub font_preset: Option<String>,
#[serde(default)]
pub bold_data: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CardBcSegmentV1 {
pub velocity_min: f64,
pub velocity_max: f64,
pub bc: f64,
}
fn default_sight_height() -> f64 {
f64::NAN
}
fn default_humidity() -> f64 {
50.0
}
fn default_cf() -> f64 {
1.0
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DragModelV1 {
G1,
#[default]
G7,
G2,
G5,
G6,
G8,
GI,
GS,
RA4,
}
impl From<DragModelV1> for DragModel {
fn from(v: DragModelV1) -> Self {
match v {
DragModelV1::G1 => DragModel::G1,
DragModelV1::G7 => DragModel::G7,
DragModelV1::G2 => DragModel::G2,
DragModelV1::G5 => DragModel::G5,
DragModelV1::G6 => DragModel::G6,
DragModelV1::G8 => DragModel::G8,
DragModelV1::GI => DragModel::GI,
DragModelV1::GS => DragModel::GS,
DragModelV1::RA4 => DragModel::RA4,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct CardRowV1 {
pub range: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_linear: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_adj: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub come_up: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub wind_linear: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub wind_adj: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub velocity: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub energy: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub time: Option<f64>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub wind_columns: Vec<f64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CardUnitsBlockV1 {
pub distance: &'static str,
pub velocity: &'static str,
pub energy: &'static str,
pub drop: &'static str,
pub wind_speed: &'static str,
pub elevation_adjustment: String,
pub windage_adjustment: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct CardResponseV1 {
pub schema_version: u32,
pub kind: &'static str,
pub zero_distance: f64,
pub bc_for_solve: f64,
pub units: CardUnitsBlockV1,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub wind_speeds: Vec<f64>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub wind_angles_deg: Vec<f64>,
pub rows: Vec<CardRowV1>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub extra_angle_rows: Vec<Vec<CardRowV1>>,
}
#[derive(Debug, thiserror::Error)]
pub enum CardServiceError {
#[error("invalid request: {0}")]
InvalidRequest(String),
#[error("zero solve failed: {0}")]
ZeroSolve(String),
#[error("trajectory failed: {0}")]
Trajectory(String),
#[cfg(feature = "pdf")]
#[error("pdf generation failed: {0}")]
Pdf(String),
#[cfg(feature = "pdf")]
#[error("{0}")]
TooLarge(String),
}
struct Units {
imperial: bool,
}
impl Units {
fn velocity_to_metric(&self, v: f64) -> f64 {
if self.imperial { v * 0.3048 } else { v }
}
fn mass_to_kg(&self, v: f64) -> f64 {
if self.imperial { v * GRAINS_TO_KG } else { v * 0.001 }
}
fn distance_to_metric(&self, v: f64) -> f64 {
if self.imperial { v * 0.9144 } else { v }
}
fn small_len_to_metric(&self, v: f64) -> f64 {
if self.imperial { v * 0.0254 } else { v * 0.001 }
}
fn wind_to_metric(&self, v: f64) -> f64 {
if self.imperial { v * 0.44704 } else { v }
}
fn temperature_to_metric(&self, v: f64) -> f64 {
if self.imperial { (v - 32.0) * 5.0 / 9.0 } else { v }
}
fn pressure_to_metric(&self, v: f64) -> f64 {
if self.imperial { v * 33.8639 } else { v }
}
fn velocity_from_metric(&self, v: f64) -> f64 {
if self.imperial { v / 0.3048 } else { v }
}
fn distance_from_metric(&self, v: f64) -> f64 {
if self.imperial { v / 0.9144 } else { v }
}
fn energy_from_metric(&self, v: f64) -> f64 {
if self.imperial { v * 0.737562 } else { v }
}
fn drop_linear_from_metric(&self, v: f64) -> f64 {
if self.imperial { v / 0.0254 } else { v * 1000.0 }
}
}
struct Resolved {
u: Units,
velocity_m: f64,
mass_kg: f64,
diameter_m: f64,
sight_height_m: f64,
zero_distance_m: f64,
zero_poi_vertical_m: f64,
zero_poi_horizontal_m: f64,
sight_offset_lateral_m: f64,
temperature_c: f64,
pressure_hpa: f64,
end_m: f64,
sample_m: f64,
elevation_click: Option<ClickValue>,
windage_click: Option<ClickValue>,
windage_unit: AdjustmentUnit,
drag_model: DragModel,
bc_for_solve: f64,
bc_segments_fps: Option<Vec<BCSegmentData>>,
}
fn resolve(req: &CardRequestV1) -> Result<Resolved, CardServiceError> {
resolve_inner(req, true)
}
#[cfg(feature = "pdf")]
fn resolve_axes_only(req: &CardRequestV1) -> Result<Resolved, CardServiceError> {
resolve_inner(req, false)
}
fn resolve_inner(req: &CardRequestV1, load_bc_schedule: bool) -> Result<Resolved, CardServiceError> {
let imperial = req.units == CardUnits::Imperial;
let u = Units { imperial };
for (name, v) in [
("muzzle_velocity", req.muzzle_velocity),
("ballistic_coefficient", req.ballistic_coefficient),
("mass", req.mass),
("diameter", req.diameter),
("zero_distance", req.zero_distance),
("start", req.start),
("end", req.end),
("step", req.step),
] {
if !v.is_finite() || v <= 0.0 {
return Err(CardServiceError::InvalidRequest(format!(
"{name} must be finite and positive, got {v}"
)));
}
}
if req.end < req.start {
return Err(CardServiceError::InvalidRequest(
"end must be >= start".into(),
));
}
if !(0.5..=1.5).contains(&req.elevation_cf) || !(0.5..=1.5).contains(&req.windage_cf) {
return Err(CardServiceError::InvalidRequest(
"tracking correction factors must lie in (0.5, 1.5)".into(),
));
}
let sight_height = if req.sight_height.is_nan() {
if imperial {
1.5
} else {
38.0
}
} else {
req.sight_height
};
let temperature = req.temperature.unwrap_or(if imperial { 59.0 } else { 15.0 });
let pressure = req.pressure.unwrap_or(if imperial { 29.92 } else { 1013.25 });
let parse_click = |label: &str, s: &Option<String>| -> Result<Option<ClickValue>, CardServiceError> {
s.as_deref()
.map(|raw| {
parse_click_value(raw).map_err(|e| {
CardServiceError::InvalidRequest(format!("{label}: {e}"))
})
})
.transpose()
};
let elevation_click = parse_click("elevation_click_value", &req.elevation_click_value)?;
let windage_click =
parse_click("windage_click_value", &req.windage_click_value)?.or(elevation_click);
if req.adjustment_unit == AdjustmentUnit::Clicks && elevation_click.is_none() {
return Err(CardServiceError::InvalidRequest(
"adjustment_unit 'clicks' requires elevation_click_value".into(),
));
}
let windage_unit = req.windage_unit.unwrap_or(req.adjustment_unit);
if windage_unit == AdjustmentUnit::Clicks && windage_click.is_none() {
return Err(CardServiceError::InvalidRequest(
"windage_unit 'clicks' requires a click graduation".into(),
));
}
let (bc_for_solve, bc_segments_fps) = if load_bc_schedule {
resolve_bc_schedule(req, imperial)?
} else {
(req.ballistic_coefficient, None)
};
Ok(Resolved {
bc_for_solve,
bc_segments_fps,
velocity_m: u.velocity_to_metric(req.muzzle_velocity),
mass_kg: u.mass_to_kg(req.mass),
diameter_m: u.small_len_to_metric(req.diameter),
sight_height_m: u.small_len_to_metric(sight_height),
zero_distance_m: u.distance_to_metric(req.zero_distance),
zero_poi_vertical_m: u.small_len_to_metric(req.zero_poi_vertical),
zero_poi_horizontal_m: u.small_len_to_metric(req.zero_poi_horizontal),
sight_offset_lateral_m: u.small_len_to_metric(req.sight_offset_lateral),
temperature_c: u.temperature_to_metric(temperature),
pressure_hpa: u.pressure_to_metric(pressure),
end_m: u.distance_to_metric(req.end),
sample_m: u.distance_to_metric(req.step),
elevation_click,
windage_click,
windage_unit,
drag_model: req.drag_model.into(),
u,
})
}
fn resolve_bc_schedule(
req: &CardRequestV1,
imperial: bool,
) -> Result<(f64, Option<Vec<BCSegmentData>>), CardServiceError> {
if let Some(segments) = req.bc_segments.as_ref().filter(|s| !s.is_empty()) {
let to_fps = if imperial { 1.0 } else { 3.280_839_895 };
let mut converted = Vec::with_capacity(segments.len());
for (index, segment) in segments.iter().enumerate() {
if !segment.velocity_min.is_finite()
|| !segment.velocity_max.is_finite()
|| !segment.bc.is_finite()
{
return Err(CardServiceError::InvalidRequest(format!(
"bc_segments[{index}]: velocity_min, velocity_max, and bc must be finite"
)));
}
if segment.velocity_min >= segment.velocity_max {
return Err(CardServiceError::InvalidRequest(format!(
"bc_segments[{index}]: velocity_min must be < velocity_max"
)));
}
if segment.bc <= 0.0 {
return Err(CardServiceError::InvalidRequest(format!(
"bc_segments[{index}]: bc must be > 0"
)));
}
converted.push(BCSegmentData {
velocity_min: segment.velocity_min * to_fps,
velocity_max: segment.velocity_max * to_fps,
bc_value: segment.bc,
});
}
return Ok((req.ballistic_coefficient, Some(converted)));
}
let Some(path) = req.bc5d_table_path.as_deref() else {
return Ok((req.ballistic_coefficient, None));
};
#[cfg(target_arch = "wasm32")]
{
let _ = path;
Err(CardServiceError::InvalidRequest(
"bc5d_table_path is not supported on this target (no filesystem); supply \
bc_segments instead"
.into(),
))
}
#[cfg(not(target_arch = "wasm32"))]
{
let diameter_in = if imperial { req.diameter } else { req.diameter / 25.4 };
let table = crate::bc_table_5d::path_cache::load_verified_for_caliber(
std::path::Path::new(path),
diameter_in,
)
.map_err(|e| CardServiceError::InvalidRequest(format!("bc5d_table_path: {e}")))?;
let weight_grains = if imperial { req.mass } else { req.mass * 15.4324 };
let muzzle_fps = if imperial {
req.muzzle_velocity
} else {
req.muzzle_velocity * 3.280_839_895
};
let drag_type = if req.drag_model == DragModelV1::G7 { "G7" } else { "G1" };
let base_bc = req.ballistic_coefficient;
match table.generate_segments(base_bc, drag_type, weight_grains, Some(muzzle_fps)) {
Some(segments) => {
let fallback_bc =
table.get_effective_bc(weight_grains, base_bc, muzzle_fps, muzzle_fps, drag_type);
Ok((fallback_bc, Some(segments)))
}
None => Ok((base_bc, None)),
}
}
}
fn solve_zero(req: &CardRequestV1, r: &Resolved) -> Result<f64, CardServiceError> {
let zero_inputs = BallisticInputs {
bc_value: r.bc_for_solve,
bc_type: r.drag_model,
bullet_mass: r.mass_kg,
muzzle_velocity: r.velocity_m,
bullet_diameter: r.diameter_m,
bullet_length: crate::truing::fallback_bullet_length_m(r.diameter_m, r.mass_kg),
sight_height: r.sight_height_m,
use_rk4: true,
zero_poi_vertical_m: r.zero_poi_vertical_m,
zero_poi_horizontal_m: r.zero_poi_horizontal_m,
sight_offset_lateral_m: r.sight_offset_lateral_m,
use_bc_segments: r.bc_segments_fps.is_some(),
bc_segments_data: r.bc_segments_fps.clone(),
..Default::default()
};
let atmosphere = AtmosphericConditions {
temperature: r.temperature_c,
pressure: r.pressure_hpa,
humidity: req.humidity,
altitude: req.altitude,
};
crate::calculate_zero_angle_with_conditions(
zero_inputs,
r.zero_distance_m,
r.sight_height_m,
WindConditions::default(),
atmosphere,
)
.map_err(|e| CardServiceError::ZeroSolve(e.to_string()))
}
#[allow(clippy::too_many_arguments)]
fn sampled(
req: &CardRequestV1,
r: &Resolved,
wind_speed_m: f64,
wind_direction_deg: f64,
zero_angle: f64,
) -> Result<Vec<crate::trajectory_sampling::TrajectorySample>, CardServiceError> {
run_sampled_trajectory(
r.velocity_m,
r.bc_for_solve,
r.mass_kg,
r.diameter_m,
r.drag_model,
r.sight_height_m,
r.temperature_c,
r.pressure_hpa,
req.humidity,
req.altitude,
wind_speed_m,
wind_direction_deg,
r.end_m * 1.1,
r.sample_m,
zero_angle,
r.bc_segments_fps.clone(),
None,
None,
r.zero_poi_vertical_m,
r.zero_poi_horizontal_m,
r.sight_offset_lateral_m,
Some(r.zero_distance_m),
)
.map_err(|e| CardServiceError::Trajectory(e.to_string()))
}
fn nearest(
samples: &[crate::trajectory_sampling::TrajectorySample],
range_m: f64,
) -> Option<&crate::trajectory_sampling::TrajectorySample> {
samples.iter().min_by(|a, b| {
(a.distance_m - range_m)
.abs()
.partial_cmp(&(b.distance_m - range_m).abs())
.unwrap()
})
}
fn units_block(req: &CardRequestV1, windage_unit: AdjustmentUnit) -> CardUnitsBlockV1 {
let imperial = req.units == CardUnits::Imperial;
CardUnitsBlockV1 {
distance: if imperial { "yd" } else { "m" },
velocity: if imperial { "fps" } else { "m/s" },
energy: if imperial { "ft-lb" } else { "J" },
drop: if imperial { "in" } else { "mm" },
wind_speed: if imperial { "mph" } else { "m/s" },
elevation_adjustment: adjustment_unit_label(req.adjustment_unit),
windage_adjustment: adjustment_unit_label(windage_unit),
}
}
pub fn come_ups_v1(req: &CardRequestV1) -> Result<CardResponseV1, CardServiceError> {
let r = resolve(req)?;
let zero_angle = solve_zero(req, &r)?;
let samples = sampled(
req,
&r,
r.u.wind_to_metric(req.wind_speed),
req.wind_direction_deg,
zero_angle,
)?;
let mut rows = Vec::new();
let mut prev_drop_adj: f64 = 0.0;
let mut current_range = req.start;
while current_range <= req.end + 0.1 {
let range_m = r.u.distance_to_metric(current_range);
if let Some(sample) = nearest(&samples, range_m) {
if (sample.distance_m - range_m).abs() < r.sample_m * 1.5 {
let drop_yd = r.u.distance_from_metric(sample.drop_m);
let range_display = r.u.distance_from_metric(sample.distance_m);
let drop_adj = adjustment_display(
drop_yd,
range_display,
req.adjustment_unit,
r.elevation_click,
req.zero_set_elevation_bias_mil,
req.elevation_cf,
)
.value;
rows.push(CardRowV1 {
range: current_range,
drop_linear: None,
drop_adj: Some(drop_adj),
come_up: Some(drop_adj - prev_drop_adj),
wind_linear: None,
wind_adj: None,
velocity: Some(r.u.velocity_from_metric(sample.velocity_mps)),
energy: Some(r.u.energy_from_metric(sample.energy_j)),
time: Some(sample.time_s),
wind_columns: Vec::new(),
});
prev_drop_adj = drop_adj;
}
}
current_range += req.step;
}
Ok(CardResponseV1 {
schema_version: CARD_SCHEMA_VERSION_V1,
kind: "come_ups",
zero_distance: req.zero_distance,
bc_for_solve: r.bc_for_solve,
units: units_block(req, req.adjustment_unit),
wind_speeds: Vec::new(),
wind_angles_deg: Vec::new(),
rows,
extra_angle_rows: Vec::new(),
})
}
pub fn range_table_v1(req: &CardRequestV1) -> Result<CardResponseV1, CardServiceError> {
range_table_rows(req, None).map(|(card, _lead, _bc)| card)
}
fn range_table_rows(
req: &CardRequestV1,
lead_target_speed: Option<f64>,
) -> Result<(CardResponseV1, Vec<Option<f64>>, f64), CardServiceError> {
let r = resolve(req)?;
let zero_angle = solve_zero(req, &r)?;
let wind_samples = sampled(
req,
&r,
r.u.wind_to_metric(req.wind_speed),
req.wind_direction_deg,
zero_angle,
)?;
let no_wind_samples = sampled(req, &r, 0.0, 0.0, zero_angle)?;
let lead_speed_mps = lead_target_speed.map(|speed| r.u.wind_to_metric(speed));
let mut rows = Vec::new();
let mut lead_adj = Vec::new();
let mut current_range = req.start;
while current_range <= req.end + 0.1 {
let range_m = r.u.distance_to_metric(current_range);
if let (Some(nw), Some(w)) = (nearest(&no_wind_samples, range_m), nearest(&wind_samples, range_m)) {
if (nw.distance_m - range_m).abs() < r.sample_m * 1.5 {
let range_display = r.u.distance_from_metric(nw.distance_m);
let drop_yd = r.u.distance_from_metric(nw.drop_m);
let drop_adj = adjustment_display(
drop_yd,
range_display,
req.adjustment_unit,
r.elevation_click,
req.zero_set_elevation_bias_mil,
req.elevation_cf,
)
.value;
let drift_yd = r.u.distance_from_metric(w.wind_drift_m);
let wind_adj = windage_adjustment_display(
drift_yd,
range_display,
r.windage_unit,
r.windage_click,
req.zero_set_windage_bias_mil,
req.windage_cf,
)
.value;
lead_adj.push(lead_speed_mps.map(|speed_mps| {
let lead_display = r.u.distance_from_metric(
crate::lead_from_tof(speed_mps, 90.0, nw.time_s, nw.distance_m).lead_m,
);
windage_adjustment_display(
lead_display,
range_display,
r.windage_unit,
r.windage_click,
0.0,
req.windage_cf,
)
.value
}));
rows.push(CardRowV1 {
range: current_range,
drop_linear: Some(r.u.drop_linear_from_metric(nw.drop_m)),
drop_adj: Some(drop_adj),
come_up: None,
wind_linear: Some(r.u.drop_linear_from_metric(w.wind_drift_m)),
wind_adj: Some(wind_adj),
velocity: Some(r.u.velocity_from_metric(nw.velocity_mps)),
energy: Some(r.u.energy_from_metric(nw.energy_j)),
time: Some(nw.time_s),
wind_columns: Vec::new(),
});
}
}
current_range += req.step;
}
debug_assert_eq!(rows.len(), lead_adj.len(), "Lead column must parallel rows");
Ok((
CardResponseV1 {
schema_version: CARD_SCHEMA_VERSION_V1,
kind: "range_table",
zero_distance: req.zero_distance,
bc_for_solve: r.bc_for_solve,
units: units_block(req, r.windage_unit),
wind_speeds: Vec::new(),
wind_angles_deg: Vec::new(),
rows,
extra_angle_rows: Vec::new(),
},
lead_adj,
r.bc_for_solve,
))
}
pub fn wind_card_v1(req: &CardRequestV1) -> Result<CardResponseV1, CardServiceError> {
if req.wind_speeds.is_empty() {
return Err(CardServiceError::InvalidRequest(
"wind_card requires at least one entry in wind_speeds".into(),
));
}
let r = resolve(req)?;
let zero_angle = solve_zero(req, &r)?;
let angles: Vec<f64> = if req.wind_angles_deg.is_empty() {
vec![90.0]
} else {
req.wind_angles_deg.clone()
};
let mut ranges: Vec<f64> = Vec::new();
let mut current = req.start;
while current <= req.end + 0.1 {
ranges.push(current);
current += req.step;
}
let mut per_angle_rows: Vec<Vec<CardRowV1>> = Vec::new();
for &angle_deg in &angles {
let mut all_drifts: Vec<Vec<f64>> = vec![Vec::new(); ranges.len()];
for &ws in &req.wind_speeds {
let samples = sampled(req, &r, r.u.wind_to_metric(ws), angle_deg, zero_angle)?;
for (ri, &range_display) in ranges.iter().enumerate() {
let range_m = r.u.distance_to_metric(range_display);
let drift_adj = match nearest(&samples, range_m) {
Some(sample) if (sample.distance_m - range_m).abs() < r.sample_m * 1.5 => {
let drift_yd = r.u.distance_from_metric(sample.wind_drift_m);
adjustment_display(
drift_yd,
range_display,
req.adjustment_unit,
r.windage_click,
req.zero_set_windage_bias_mil,
req.windage_cf,
)
.value
}
_ => 0.0,
};
all_drifts[ri].push(drift_adj);
}
}
per_angle_rows.push(
ranges
.iter()
.enumerate()
.map(|(i, &range)| CardRowV1 {
range,
drop_linear: None,
drop_adj: None,
come_up: None,
wind_linear: None,
wind_adj: None,
velocity: None,
energy: None,
time: None,
wind_columns: all_drifts[i].clone(),
})
.collect(),
);
}
let first = per_angle_rows.remove(0);
Ok(CardResponseV1 {
schema_version: CARD_SCHEMA_VERSION_V1,
kind: "wind_card",
zero_distance: req.zero_distance,
bc_for_solve: r.bc_for_solve,
units: units_block(req, req.adjustment_unit),
wind_speeds: req.wind_speeds.clone(),
wind_angles_deg: angles,
rows: first,
extra_angle_rows: per_angle_rows,
})
}
#[cfg(feature = "pdf")]
pub const PDF_CARD_KIND: &str = "range_table";
#[cfg(feature = "pdf")]
pub const MAX_PDF_ROWS: usize = 5_000;
#[cfg(feature = "pdf")]
pub const MAX_PDF_PAGES: usize = 60;
#[cfg(feature = "pdf")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PdfRowSource {
Solve,
StoredRows,
}
#[cfg(feature = "pdf")]
impl PdfRowSource {
pub fn as_str(self) -> &'static str {
match self {
Self::Solve => "solve",
Self::StoredRows => "stored_rows",
}
}
}
#[cfg(feature = "pdf")]
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StoredCardV1 {
pub card: StoredCardResponseV1,
#[serde(default)]
pub engine_version: Option<String>,
#[serde(default)]
pub bc5d_table_version: Option<String>,
}
#[cfg(feature = "pdf")]
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
pub struct StoredCardResponseV1 {
pub kind: String,
pub units: StoredCardUnitsBlockV1,
pub rows: Vec<StoredCardRowV1>,
#[serde(default)]
pub schema_version: Option<u32>,
#[serde(default)]
pub zero_distance: Option<f64>,
#[serde(default)]
pub bc_for_solve: Option<f64>,
#[serde(default)]
pub wind_speeds: Vec<f64>,
#[serde(default)]
pub wind_angles_deg: Vec<f64>,
#[serde(default)]
pub extra_angle_rows: Vec<Vec<StoredCardRowV1>>,
}
#[cfg(feature = "pdf")]
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
pub struct StoredCardUnitsBlockV1 {
pub distance: String,
pub elevation_adjustment: String,
pub windage_adjustment: String,
#[serde(default)]
pub velocity: Option<String>,
#[serde(default)]
pub energy: Option<String>,
#[serde(default)]
pub drop: Option<String>,
#[serde(default)]
pub wind_speed: Option<String>,
}
#[cfg(feature = "pdf")]
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
pub struct StoredCardRowV1 {
pub range: f64,
#[serde(default)]
pub drop_linear: Option<f64>,
#[serde(default)]
pub drop_adj: Option<f64>,
#[serde(default)]
pub come_up: Option<f64>,
#[serde(default)]
pub wind_linear: Option<f64>,
#[serde(default)]
pub wind_adj: Option<f64>,
#[serde(default)]
pub velocity: Option<f64>,
#[serde(default)]
pub energy: Option<f64>,
#[serde(default)]
pub time: Option<f64>,
#[serde(default)]
pub wind_columns: Vec<f64>,
}
#[cfg(feature = "pdf")]
#[derive(Debug, Clone)]
pub struct PdfCardV1 {
pub pdf_bytes: Vec<u8>,
pub page_count: usize,
pub row_count: usize,
pub source: PdfRowSource,
pub unprintable_title_chars: String,
}
#[cfg(feature = "pdf")]
fn resolve_font_scale(opts: &PdfCardOptionsV1) -> Result<f32, CardServiceError> {
use crate::pdf_dope_card::{FontSizePreset, FONT_SCALE_RANGE};
match (opts.font_scale, opts.font_preset.as_deref()) {
(Some(_), Some(_)) => Err(CardServiceError::InvalidRequest(
"pdf.font_scale and pdf.font_preset are mutually exclusive; supply one".into(),
)),
(Some(scale), None) => {
let scale = scale as f32;
if !scale.is_finite() || !FONT_SCALE_RANGE.contains(&scale) {
return Err(CardServiceError::InvalidRequest(format!(
"pdf.font_scale must be finite and within {}..={}, got {}",
FONT_SCALE_RANGE.start(),
FONT_SCALE_RANGE.end(),
opts.font_scale.unwrap_or(f64::NAN)
)));
}
Ok(scale)
}
(None, Some(preset)) => FontSizePreset::from_str(preset).map(|p| p.scale()).ok_or_else(|| {
CardServiceError::InvalidRequest(format!(
"pdf.font_preset '{preset}' is not one of small, medium, large"
))
}),
(None, None) => Ok(1.0),
}
}
#[cfg(feature = "pdf")]
struct RowsToPrint {
rows: Vec<crate::card::CardRow>,
source: PdfRowSource,
bc: f64,
engine_version: String,
table_version: String,
elevation_unit_label: String,
windage_unit_label: String,
}
#[cfg(feature = "pdf")]
fn stored_rows_to_print(
stored: &StoredCardResponseV1,
req: &CardRequestV1,
r: &Resolved,
target_speed: Option<f64>,
) -> Vec<crate::card::CardRow> {
use crate::card::CardRow;
let lead_speed_mps = target_speed.map(|speed| r.u.wind_to_metric(speed));
stored
.rows
.iter()
.map(|row| CardRow {
range: row.range,
drop_linear: row.drop_linear,
drop_adj: row.drop_adj,
come_up: row.come_up,
wind_linear: row.wind_linear,
wind_adj: row.wind_adj,
velocity: row.velocity,
energy: row.energy,
time: row.time,
lead_adj: lead_speed_mps.zip(row.time).map(|(speed_mps, tof_s)| {
let range_m = r.u.distance_to_metric(row.range);
let lead_display =
r.u.distance_from_metric(crate::lead_from_tof(speed_mps, 90.0, tof_s, range_m).lead_m);
windage_adjustment_display(
lead_display,
row.range,
r.windage_unit,
r.windage_click,
0.0,
req.windage_cf,
)
.value
}),
wind_columns: row.wind_columns.clone(),
})
.collect()
}
#[cfg(feature = "pdf")]
fn validate_stored_card(
stored: &StoredCardV1,
req: &CardRequestV1,
r: &Resolved,
) -> Result<(), CardServiceError> {
let card = &stored.card;
if card.kind != PDF_CARD_KIND {
return Err(CardServiceError::InvalidRequest(format!(
"card.pdf prints a {PDF_CARD_KIND} card; the stored card's kind is '{}'. Its \
columns are not a range table's, so it is refused rather than reprinted as one",
card.kind
)));
}
for (field, present) in [
("wind_speeds", !card.wind_speeds.is_empty()),
("wind_angles_deg", !card.wind_angles_deg.is_empty()),
("extra_angle_rows", !card.extra_angle_rows.is_empty()),
] {
if present {
return Err(CardServiceError::InvalidRequest(format!(
"the stored card carries {field}, a wind card's defining field, so it is not the \
{PDF_CARD_KIND} response it claims to be"
)));
}
}
if card.rows.is_empty() {
return Err(CardServiceError::InvalidRequest(
"the stored card has no rows; an empty dope card is not a document".into(),
));
}
let elevation = adjustment_unit_label(req.adjustment_unit);
let windage = adjustment_unit_label(r.windage_unit);
let distance = if req.units == CardUnits::Imperial { "yd" } else { "m" };
for (field, stored_label, request_label) in [
("units.distance", card.units.distance.as_str(), distance),
(
"units.elevation_adjustment",
card.units.elevation_adjustment.as_str(),
elevation.as_str(),
),
(
"units.windage_adjustment",
card.units.windage_adjustment.as_str(),
windage.as_str(),
),
] {
if stored_label != request_label {
return Err(CardServiceError::InvalidRequest(format!(
"the stored card's {field} is '{stored_label}' but this request's own axes say \
'{request_label}' — the stored rows and the request are not the same card"
)));
}
}
for (index, row) in card.rows.iter().enumerate() {
for (field, value) in [
("range", Some(row.range)),
("drop_adj", row.drop_adj),
("wind_adj", row.wind_adj),
("time", row.time),
] {
if value.is_some_and(|v| !v.is_finite()) {
return Err(CardServiceError::InvalidRequest(format!(
"the stored card's rows[{index}].{field} is not a finite number"
)));
}
}
}
Ok(())
}
#[cfg(feature = "pdf")]
pub fn pdf_card_v1(
req: &CardRequestV1,
stored: Option<&StoredCardV1>,
) -> Result<PdfCardV1, CardServiceError> {
use crate::pdf_dope_card::{
calculate_density_altitude, dope_card_page_count, generate_dope_card_pdf, DopeCardConfig,
RangeUnit,
};
let opts = req.pdf.clone().unwrap_or_default();
let font_scale = resolve_font_scale(&opts)?;
if opts.target_speed.is_some_and(|speed| !speed.is_finite()) {
return Err(CardServiceError::InvalidRequest(
"pdf.target_speed must be finite".into(),
));
}
for (field, present) in [
("wind_speeds", !req.wind_speeds.is_empty()),
("wind_angles_deg", !req.wind_angles_deg.is_empty()),
] {
if present {
return Err(CardServiceError::InvalidRequest(format!(
"card.pdf prints a {PDF_CARD_KIND} card; this request carries {field}, a wind \
card's defining field, which a range-table PDF cannot show and would silently \
ignore. There is no wind_card or come_ups PDF in this build"
)));
}
}
let to_print = match stored {
Some(stored) => {
let r = resolve_axes_only(req)?;
validate_stored_card(stored, req, &r)?;
RowsToPrint {
rows: stored_rows_to_print(&stored.card, req, &r, opts.target_speed),
source: PdfRowSource::StoredRows,
bc: stored.card.bc_for_solve.unwrap_or(req.ballistic_coefficient),
engine_version: stored.engine_version.clone().unwrap_or_default(),
table_version: stored.bc5d_table_version.clone().unwrap_or_default(),
elevation_unit_label: stored.card.units.elevation_adjustment.clone(),
windage_unit_label: stored.card.units.windage_adjustment.clone(),
}
}
None => {
let (card, lead_adj, bc_for_solve) = range_table_rows(req, opts.target_speed)?;
if card.rows.is_empty() {
return Err(CardServiceError::InvalidRequest(format!(
"no card rows in {}..={} — the trajectory does not reach this range domain, \
or step is coarser than the samples",
req.start, req.end
)));
}
RowsToPrint {
rows: card
.rows
.iter()
.zip(&lead_adj)
.map(|(row, lead)| crate::card::CardRow {
range: row.range,
drop_linear: row.drop_linear,
drop_adj: row.drop_adj,
come_up: row.come_up,
wind_linear: row.wind_linear,
wind_adj: row.wind_adj,
velocity: row.velocity,
energy: row.energy,
time: row.time,
lead_adj: *lead,
wind_columns: row.wind_columns.clone(),
})
.collect(),
source: PdfRowSource::Solve,
bc: bc_for_solve,
engine_version: env!("CARGO_PKG_VERSION").to_string(),
table_version: String::new(),
elevation_unit_label: card.units.elevation_adjustment.clone(),
windage_unit_label: card.units.windage_adjustment.clone(),
}
}
};
let page_count = dope_card_page_count(to_print.rows.len(), font_scale);
if to_print.rows.len() > MAX_PDF_ROWS || page_count > MAX_PDF_PAGES {
return Err(CardServiceError::TooLarge(format!(
"this card has too many rows to print: {} rows, {page_count} pages (the limits are \
{MAX_PDF_ROWS} rows and {MAX_PDF_PAGES} pages)",
to_print.rows.len()
)));
}
let imperial = req.units == CardUnits::Imperial;
let temperature = req.temperature.unwrap_or(if imperial { 59.0 } else { 15.0 });
let pressure = req.pressure.unwrap_or(if imperial { 29.92 } else { 1013.25 });
let pressure_inhg = if imperial { pressure } else { pressure / 33.8639 };
let pressure_hpa = if imperial { pressure * 33.8639 } else { pressure };
let temperature_f = if imperial { temperature } else { temperature * 9.0 / 5.0 + 32.0 };
let altitude_ft = req.altitude / 0.3048;
let config = DopeCardConfig {
rifle_name: opts.title.clone().unwrap_or_else(|| "Dope Card".to_string()),
location: opts.location.clone().unwrap_or_default(),
density_altitude_ft: calculate_density_altitude(altitude_ft, pressure_inhg, temperature_f),
pressure_inhg,
pressure_hpa,
temperature_f,
altitude_ft,
wind_speed_mph: if imperial { req.wind_speed } else { req.wind_speed / 0.44704 },
target_speed_mph: match opts.target_speed {
Some(speed) if imperial => speed,
Some(speed) => speed / 0.44704,
None => 0.0,
},
solver_mode: if cfg!(feature = "online") { "online".to_string() } else { "offline".to_string() },
powder: opts.powder.clone().unwrap_or_default(),
bullet: opts.bullet.clone().unwrap_or_default(),
weight_gr: if imperial { req.mass } else { req.mass * 15.4324 },
bc: to_print.bc,
drag_model: DragModel::from(req.drag_model).to_string(),
velocity_fps: if imperial { req.muzzle_velocity } else { req.muzzle_velocity / 0.3048 },
font_scale,
bold_data: opts.bold_data,
elevation_unit_label: to_print.elevation_unit_label.clone(),
windage_unit_label: to_print.windage_unit_label.clone(),
engine_version: to_print.engine_version.clone(),
table_version: to_print.table_version.clone(),
};
let range_unit = if imperial { RangeUnit::Yards } else { RangeUnit::Meters };
let unprintable_title_chars = crate::pdf_dope_card::unprintable_chars(&config.rifle_name);
let pdf_bytes = generate_dope_card_pdf(&config, &to_print.rows, range_unit)
.map_err(|e| CardServiceError::Pdf(e.to_string()))?;
Ok(PdfCardV1 {
page_count,
row_count: to_print.rows.len(),
source: to_print.source,
pdf_bytes,
unprintable_title_chars,
})
}