1use crate::adjustment::ClickValue;
10use crate::profile::{ProfileBcSegment, ProfileData, ProfileDragPoint, ProfileZeroSet};
11
12fn timestamp_string() -> String {
16 use std::time::{SystemTime, UNIX_EPOCH};
17 let secs = SystemTime::now()
18 .duration_since(UNIX_EPOCH)
19 .unwrap_or_default()
20 .as_secs();
21 format!("{}", secs)
22}
23
24#[derive(Debug)]
27pub struct ImportReport {
28 pub mapped: Vec<[String; 4]>,
30 pub unmapped: Vec<(String, String)>,
32 pub warnings: Vec<String>,
33}
34
35#[derive(Debug)]
36pub struct A7pImportOutcome {
37 pub profile: ProfileData,
38 pub report: ImportReport,
39}
40
41use crate::constants::GRAMS_PER_GRAIN as GRAIN_TO_GRAM;
44const IN_TO_MM: f64 = 25.4;
45
46pub fn sanitize_profile_name(raw: &str) -> String {
49 let cleaned: String = raw
50 .chars()
51 .map(|c| {
52 if c.is_ascii_alphanumeric() || matches!(c, ' ' | '.' | '_' | '-') {
53 c
54 } else {
55 '_'
56 }
57 })
58 .collect();
59 let trimmed = cleaned.trim().to_string();
60 if trimmed.is_empty() {
61 "imported-a7p".to_string()
62 } else {
63 trimmed
64 }
65}
66
67fn click_value_to_profile_string(click: ClickValue) -> String {
72 serde_json::to_string(&click)
73 .expect("ClickValue serialization is infallible")
74 .trim_matches('"')
75 .to_string()
76}
77
78pub fn map_a7p_to_profile(
79 doc: &super::A7pDocument,
80 name_override: Option<&str>,
81 zero_click: Option<ClickValue>,
86) -> Result<A7pImportOutcome, String> {
87 use super::{A7pBcType, EnvelopeStatus};
88 let src = &doc.profile;
89
90 let mut report = ImportReport {
91 mapped: Vec::new(),
92 unmapped: Vec::new(),
93 warnings: Vec::new(),
94 };
95 if let EnvelopeStatus::Mismatch { expected, actual } = &doc.envelope {
96 report.warnings.push(format!(
97 "checksum mismatch (file says {expected}, payload hashes to {actual}) — file may be corrupted"
98 ));
99 }
100
101 let name = match name_override {
102 Some(n) => n.to_string(),
103 None => sanitize_profile_name(&src.profile_name),
104 };
105
106 let mut push = |field: &str, raw: String, converted: String, dest: &str| {
107 report
108 .mapped
109 .push([field.to_string(), raw, converted, dest.to_string()]);
110 };
111 push(
112 "profile_name",
113 src.profile_name.clone(),
114 name.clone(),
115 "name",
116 );
117 push(
118 "c_muzzle_velocity",
119 format!("{:.1} m/s", src.muzzle_velocity_mps),
120 format!("{:.1} m/s", src.muzzle_velocity_mps),
121 "velocity (muzzle velocity)",
122 );
123
124 let (drag_model, bc, bc_segments, drag_curve): (
128 &str,
129 f64,
130 Option<Vec<ProfileBcSegment>>,
131 Option<Vec<ProfileDragPoint>>,
132 ) = match src.bc_type {
133 A7pBcType::G1 | A7pBcType::G7 => {
134 let drag_model = if matches!(src.bc_type, A7pBcType::G1) {
135 "G1"
136 } else {
137 "G7"
138 };
139 let rows = src.bc_rows();
140 let (bc, bc_row_velocity) = rows.iter().copied().max_by(|a, b| a.1.total_cmp(&b.1)).ok_or_else(
143 || "no BC rows in file — cannot build a profile without a BC".to_string(),
144 )?;
145 push(
146 "coef_rows[fastest]",
147 format!("BC {bc:.3} @ {bc_row_velocity:.0} m/s"),
148 format!("{bc:.3} ({drag_model})"),
149 "bc + drag_model",
150 );
151
152 let bc_segments = if rows.len() > 1 {
153 let mut sorted = rows.clone();
157 sorted.sort_by(|a, b| b.1.total_cmp(&a.1));
158 push(
159 "coef_rows[all]",
160 format!("{} row(s), fastest {bc:.3} @ {bc_row_velocity:.0} m/s", rows.len()),
161 format!("{} bc_segments (velocity-banded, descending)", sorted.len()),
162 "bc_segments",
163 );
164 Some(
165 sorted
166 .into_iter()
167 .map(|(bc, velocity_mps)| ProfileBcSegment { bc, velocity_mps })
168 .collect(),
169 )
170 } else {
171 None
172 };
173 (drag_model, bc, bc_segments, None)
174 }
175 A7pBcType::Custom => {
176 let mut pairs = src.custom_rows(); pairs.sort_by(|a, b| a.1.total_cmp(&b.1)); let mach_values: Vec<f64> = pairs.iter().map(|&(_, mach)| mach).collect();
179 let cd_values: Vec<f64> = pairs.iter().map(|&(cd, _)| cd).collect();
180 crate::drag::DragTable::try_new(mach_values.clone(), cd_values.clone())
183 .map_err(|e| format!("CUSTOM drag curve is invalid: {e}"))?;
184 push(
185 "coef_rows[custom]",
186 format!("{} Cd/Mach row(s)", pairs.len()),
187 format!(
188 "{} drag_curve point(s), Mach {:.3}-{:.3}",
189 pairs.len(),
190 mach_values.first().copied().unwrap_or(0.0),
191 mach_values.last().copied().unwrap_or(0.0)
192 ),
193 "drag_curve",
194 );
195 report.warnings.push(
204 "bc_type CUSTOM: no scalar BC applies to a full drag curve; the profile's 'bc' \
205 field is set to 0.0 as an inert sentinel. It is unused once drag_curve is \
206 consumed (a custom drag table replaces the BC-based retardation model \
207 entirely); commands that do not yet consume drag_curve will fail loudly \
208 (bc_value must be > 0) rather than silently assuming a G1 model — see \
209 CLI_USAGE.md."
210 .to_string(),
211 );
212 let drag_curve = Some(
213 pairs
214 .into_iter()
215 .map(|(cd, mach)| ProfileDragPoint { mach, cd })
216 .collect(),
217 );
218 ("CUSTOM", 0.0, None, drag_curve)
219 }
220 A7pBcType::Other(v) => {
221 return Err(format!("unknown bc_type {v} — file newer than this importer"))
222 }
223 };
224
225 push(
226 "b_weight",
227 format!("{:.1} gr", src.bullet_weight_gr),
228 format!("{:.4} g", src.bullet_weight_gr * GRAIN_TO_GRAM),
229 "mass",
230 );
231 push(
232 "b_diameter",
233 format!("{:.3} in", src.bullet_diameter_in),
234 format!("{:.3} mm", src.bullet_diameter_in * IN_TO_MM),
235 "diameter",
236 );
237 push(
238 "b_length",
239 format!("{:.3} in", src.bullet_length_in),
240 format!("{:.2} mm", src.bullet_length_in * IN_TO_MM),
241 "bullet_length",
242 );
243 push(
244 "r_twist / twist_dir",
245 format!(
246 "{:.2} in/turn, {}",
247 src.twist_in_per_turn,
248 if src.twist_right { "RIGHT" } else { "LEFT" }
249 ),
250 format!("{:.1} mm/turn", src.twist_in_per_turn * IN_TO_MM),
251 "twist_rate + twist_right",
252 );
253 push(
254 "sc_height",
255 format!("{:.0} mm", src.sight_height_mm),
256 format!("{:.0} mm", src.sight_height_mm),
257 "sight_height",
258 );
259 if let Some(zd) = src.zero_distance_m {
260 push(
261 "distances[c_zero_distance_idx]",
262 format!("{zd:.1} m"),
263 format!("{zd:.1} m"),
264 "zero_distance + auto_zero",
265 );
266 }
267 push(
268 "c_zero_air_temperature",
269 format!("{:.1} C", src.air_temperature_c),
270 format!("{:.1} C", src.air_temperature_c),
271 "temperature",
272 );
273 push(
274 "c_zero_air_pressure",
275 format!("{:.1} hPa", src.air_pressure_hpa),
276 format!("{:.1} hPa", src.air_pressure_hpa),
277 "pressure",
278 );
279 push(
280 "c_zero_air_humidity",
281 format!("{:.0} %", src.air_humidity_pct),
282 format!("{:.0} %", src.air_humidity_pct),
283 "humidity",
284 );
285 if !src.bullet_name.is_empty() {
286 push(
287 "bullet_name",
288 src.bullet_name.clone(),
289 src.bullet_name.clone(),
290 "bullet_name",
291 );
292 }
293
294 let tcoeff_mps_per_c =
296 src.muzzle_velocity_mps * (src.temp_coeff_pct_per_15c / 100.0) / 15.0;
297 report.unmapped.push((
298 "c_t_coeff".to_string(),
299 format!(
300 "{:.3} %/15C = {:.3} m/s per C powder sensitivity — profile schema does not model \
301 powder sensitivity",
302 src.temp_coeff_pct_per_15c, tcoeff_mps_per_c
303 ),
304 ));
305 report.unmapped.push((
306 "c_zero_p_temperature".to_string(),
307 format!("{:.0} C powder temperature at zeroing", src.powder_temperature_c),
308 ));
309 report.unmapped.push((
310 "c_zero_temperature".to_string(),
311 format!(
312 "{:.0} C ambient temperature when the zero was established",
313 src.zero_temperature_c
314 ),
315 ));
316 if src.w_pitch_raw != 0 {
317 report.unmapped.push((
318 "c_zero_w_pitch".to_string(),
319 format!(
320 "zeroing pitch value ({}) — base pitch is rifle-mount geometry outside \
321 the turret model (OpticProfile has no base-pitch concept); its effect \
322 is already absorbed by zeroing, not a turret setting left to store",
323 src.w_pitch_raw
324 ),
325 ));
326 }
327 let click_from_zero_click: Option<String> = zero_click.map(click_value_to_profile_string);
344 if let Some(click_str) = &click_from_zero_click {
345 push(
346 "--zero-click",
347 format!("{click_str} (device click size, supplied on the command line)"),
348 click_str.clone(),
349 "elevation_click + windage_click",
350 );
351 }
352 let mut zero_poi_up_m: Option<f64> = None;
353 let mut zero_poi_right_m: Option<f64> = None;
354 let mut zero_sets: Option<Vec<ProfileZeroSet>> = None;
355 if src.zero_x_raw != 0 || src.zero_y_raw != 0 {
356 match (zero_click, src.zero_distance_m) {
357 (Some(click), Some(zero_distance_m)) if zero_distance_m > 0.0 => {
358 let click_rad =
359 click.size / crate::adjustment::adjustment_factor(click.base);
360 let up_clicks = f64::from(src.zero_y_raw) / 1000.0;
361 let right_clicks = -f64::from(src.zero_x_raw) / 1000.0;
362 let up_m = up_clicks * click_rad * zero_distance_m;
363 let right_m = right_clicks * click_rad * zero_distance_m;
364 zero_poi_up_m = Some(up_m);
365 zero_poi_right_m = Some(right_m);
366 zero_sets = Some(vec![ProfileZeroSet {
373 name: "a7p-zero".to_string(),
374 zero_distance: None,
375 poi_up_mil: Some(-(up_clicks * click_rad * 1000.0)),
376 poi_right_mil: Some(-(right_clicks * click_rad * 1000.0)),
377 notes: Some("imported .a7p zero_x/zero_y click state".to_string()),
378 }]);
379 push(
380 "zero_x / zero_y",
381 format!(
382 "({}, {}) raw = {:.2} right / {:.2} up device clicks",
383 src.zero_x_raw, src.zero_y_raw, right_clicks, up_clicks
384 ),
385 format!(
386 "POI {:.2} cm up / {:.2} cm right at {zero_distance_m:.0} m \
387 (--zero-click {}{})",
388 up_m * 100.0,
389 right_m * 100.0,
390 click.size,
391 match click.base {
392 crate::adjustment::ClickBase::Mil => "mil",
393 crate::adjustment::ClickBase::Moa => "moa",
394 crate::adjustment::ClickBase::Smoa => "smoa",
395 },
396 ),
397 "zero_poi_up_m + zero_poi_right_m + zero_sets[a7p-zero]",
398 );
399 }
400 (Some(_), _) => {
401 report.unmapped.push((
402 "zero_x / zero_y".to_string(),
403 format!(
404 "scope zeroing click offsets ({}, {}) — the file stores no zero \
405 distance, so --zero-click cannot convert them to a POI offset",
406 src.zero_x_raw, src.zero_y_raw
407 ),
408 ));
409 }
410 (None, _) => {
413 report.unmapped.push((
414 "zero_x / zero_y".to_string(),
415 format!(
416 "scope zeroing click offsets ({}, {}) — device click size not \
417 supplied; pass --zero-click to record the profile's turret \
418 graduation and convert this offset",
419 src.zero_x_raw, src.zero_y_raw
420 ),
421 ));
422 }
423 }
424 }
425 if !src.distances_m.is_empty() {
426 report.unmapped.push((
427 "distances".to_string(),
428 format!("{} range-card entries (device UI list)", src.distances_m.len()),
429 ));
430 }
431 if src.switches_count > 0 {
432 report.unmapped.push((
433 "switches".to_string(),
434 format!("{} device UI switch entries", src.switches_count),
435 ));
436 }
437 for (field, value) in [
438 ("cartridge_name", &src.cartridge_name),
439 ("caliber", &src.caliber),
440 ("short_name_top", &src.short_name_top),
441 ("short_name_bot", &src.short_name_bot),
442 ("device_uuid", &src.device_uuid),
443 ] {
444 if !value.is_empty() {
445 report
446 .unmapped
447 .push((field.to_string(), format!("\"{}\"", value.trim())));
448 }
449 }
450 if !src.user_note.trim().is_empty() {
451 report
452 .unmapped
453 .push(("user_note".to_string(), format!("\"{}\"", src.user_note.trim())));
454 }
455 for unknown in &doc.unknown_fields {
456 report.unmapped.push((
457 format!("{} field #{}", unknown.context, unknown.number),
458 "unknown field (file newer than this importer)".to_string(),
459 ));
460 }
461
462 let profile = ProfileData {
463 name,
464 velocity: src.muzzle_velocity_mps,
465 bc,
466 mass: src.bullet_weight_gr * GRAIN_TO_GRAM,
467 diameter: src.bullet_diameter_in * IN_TO_MM,
468 drag_model: drag_model.to_string(),
469 twist_rate: Some(src.twist_in_per_turn * IN_TO_MM),
470 sight_height: Some(src.sight_height_mm),
471 zero_distance: src.zero_distance_m,
472 units: "metric".to_string(),
473 temperature: src.air_temperature_c,
474 pressure: src.air_pressure_hpa,
475 humidity: src.air_humidity_pct,
476 altitude: 0.0,
477 bullet_name: if src.bullet_name.is_empty() {
478 None
479 } else {
480 Some(src.bullet_name.clone())
481 },
482 created: Some(timestamp_string()),
483 wind_speed: None,
484 wind_direction: None,
485 shooting_angle: None,
486 auto_zero: src.zero_distance_m,
487 twist_right: Some(src.twist_right),
488 use_bc_segments: None,
494 bullet_length: Some(src.bullet_length_in * IN_TO_MM),
495 elevation_click: click_from_zero_click.clone(),
499 windage_click: click_from_zero_click,
500 bc_segments,
501 drag_curve,
502 dsf_points: None,
504 bc_reference: None,
508 pressure_reference: None,
512 density_altitude: None,
516 zero_poi_up_m,
519 zero_poi_right_m,
520 sight_offset_lateral_m: None,
522 elevation_cf: None,
524 windage_cf: None,
525 zero_sets,
527 reticle: None,
530 clicks_per_revolution: None,
534 zero_stop: None,
535 elevation_travel_up_mil: None,
536 elevation_travel_down_mil: None,
537 windage_travel_left_mil: None,
538 windage_travel_right_mil: None,
539 turret_elevation_dialed_mil: None,
540 turret_windage_dialed_mil: None,
541 hold_bound_up_mil: None,
542 hold_bound_down_mil: None,
543 hold_bound_left_mil: None,
544 hold_bound_right_mil: None,
545 };
546
547 Ok(A7pImportOutcome { profile, report })
548}