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
143 .iter()
144 .copied()
145 .max_by(|a, b| a.1.total_cmp(&b.1))
146 .ok_or_else(|| {
147 "no BC rows in file — cannot build a profile without a BC".to_string()
148 })?;
149 push(
150 "coef_rows[fastest]",
151 format!("BC {bc:.3} @ {bc_row_velocity:.0} m/s"),
152 format!("{bc:.3} ({drag_model})"),
153 "bc + drag_model",
154 );
155
156 let bc_segments = if rows.len() > 1 {
157 let mut sorted = rows.clone();
161 sorted.sort_by(|a, b| b.1.total_cmp(&a.1));
162 push(
163 "coef_rows[all]",
164 format!(
165 "{} row(s), fastest {bc:.3} @ {bc_row_velocity:.0} m/s",
166 rows.len()
167 ),
168 format!("{} bc_segments (velocity-banded, descending)", sorted.len()),
169 "bc_segments",
170 );
171 Some(
172 sorted
173 .into_iter()
174 .map(|(bc, velocity_mps)| ProfileBcSegment { bc, velocity_mps })
175 .collect(),
176 )
177 } else {
178 None
179 };
180 (drag_model, bc, bc_segments, None)
181 }
182 A7pBcType::Custom => {
183 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();
186 let cd_values: Vec<f64> = pairs.iter().map(|&(cd, _)| cd).collect();
187 crate::drag::DragTable::try_new(mach_values.clone(), cd_values.clone())
190 .map_err(|e| format!("CUSTOM drag curve is invalid: {e}"))?;
191 push(
192 "coef_rows[custom]",
193 format!("{} Cd/Mach row(s)", pairs.len()),
194 format!(
195 "{} drag_curve point(s), Mach {:.3}-{:.3}",
196 pairs.len(),
197 mach_values.first().copied().unwrap_or(0.0),
198 mach_values.last().copied().unwrap_or(0.0)
199 ),
200 "drag_curve",
201 );
202 report.warnings.push(
211 "bc_type CUSTOM: no scalar BC applies to a full drag curve; the profile's 'bc' \
212 field is set to 0.0 as an inert sentinel. It is unused once drag_curve is \
213 consumed (a custom drag table replaces the BC-based retardation model \
214 entirely); commands that do not yet consume drag_curve will fail loudly \
215 (bc_value must be > 0) rather than silently assuming a G1 model — see \
216 CLI_USAGE.md."
217 .to_string(),
218 );
219 let drag_curve = Some(
220 pairs
221 .into_iter()
222 .map(|(cd, mach)| ProfileDragPoint { mach, cd })
223 .collect(),
224 );
225 ("CUSTOM", 0.0, None, drag_curve)
226 }
227 A7pBcType::Other(v) => {
228 return Err(format!(
229 "unknown bc_type {v} — file newer than this importer"
230 ))
231 }
232 };
233
234 push(
235 "b_weight",
236 format!("{:.1} gr", src.bullet_weight_gr),
237 format!("{:.4} g", src.bullet_weight_gr * GRAIN_TO_GRAM),
238 "mass",
239 );
240 push(
241 "b_diameter",
242 format!("{:.3} in", src.bullet_diameter_in),
243 format!("{:.3} mm", src.bullet_diameter_in * IN_TO_MM),
244 "diameter",
245 );
246 push(
247 "b_length",
248 format!("{:.3} in", src.bullet_length_in),
249 format!("{:.2} mm", src.bullet_length_in * IN_TO_MM),
250 "bullet_length",
251 );
252 push(
253 "r_twist / twist_dir",
254 format!(
255 "{:.2} in/turn, {}",
256 src.twist_in_per_turn,
257 if src.twist_right { "RIGHT" } else { "LEFT" }
258 ),
259 format!("{:.1} mm/turn", src.twist_in_per_turn * IN_TO_MM),
260 "twist_rate + twist_right",
261 );
262 push(
263 "sc_height",
264 format!("{:.0} mm", src.sight_height_mm),
265 format!("{:.0} mm", src.sight_height_mm),
266 "sight_height",
267 );
268 if let Some(zd) = src.zero_distance_m {
269 push(
270 "distances[c_zero_distance_idx]",
271 format!("{zd:.1} m"),
272 format!("{zd:.1} m"),
273 "zero_distance + auto_zero",
274 );
275 }
276 push(
277 "c_zero_air_temperature",
278 format!("{:.1} C", src.air_temperature_c),
279 format!("{:.1} C", src.air_temperature_c),
280 "temperature",
281 );
282 push(
283 "c_zero_air_pressure",
284 format!("{:.1} hPa", src.air_pressure_hpa),
285 format!("{:.1} hPa", src.air_pressure_hpa),
286 "pressure",
287 );
288 push(
289 "c_zero_air_humidity",
290 format!("{:.0} %", src.air_humidity_pct),
291 format!("{:.0} %", src.air_humidity_pct),
292 "humidity",
293 );
294 if !src.bullet_name.is_empty() {
295 push(
296 "bullet_name",
297 src.bullet_name.clone(),
298 src.bullet_name.clone(),
299 "bullet_name",
300 );
301 }
302
303 let tcoeff_mps_per_c = src.muzzle_velocity_mps * (src.temp_coeff_pct_per_15c / 100.0) / 15.0;
305 report.unmapped.push((
306 "c_t_coeff".to_string(),
307 format!(
308 "{:.3} %/15C = {:.3} m/s per C powder sensitivity — profile schema does not model \
309 powder sensitivity",
310 src.temp_coeff_pct_per_15c, tcoeff_mps_per_c
311 ),
312 ));
313 report.unmapped.push((
314 "c_zero_p_temperature".to_string(),
315 format!(
316 "{:.0} C powder temperature at zeroing",
317 src.powder_temperature_c
318 ),
319 ));
320 report.unmapped.push((
321 "c_zero_temperature".to_string(),
322 format!(
323 "{:.0} C ambient temperature when the zero was established",
324 src.zero_temperature_c
325 ),
326 ));
327 if src.w_pitch_raw != 0 {
328 report.unmapped.push((
329 "c_zero_w_pitch".to_string(),
330 format!(
331 "zeroing pitch value ({}) — base pitch is rifle-mount geometry outside \
332 the turret model (OpticProfile has no base-pitch concept); its effect \
333 is already absorbed by zeroing, not a turret setting left to store",
334 src.w_pitch_raw
335 ),
336 ));
337 }
338 let click_from_zero_click: Option<String> = zero_click.map(click_value_to_profile_string);
355 if let Some(click_str) = &click_from_zero_click {
356 push(
357 "--zero-click",
358 format!("{click_str} (device click size, supplied on the command line)"),
359 click_str.clone(),
360 "elevation_click + windage_click",
361 );
362 }
363 let mut zero_poi_up_m: Option<f64> = None;
364 let mut zero_poi_right_m: Option<f64> = None;
365 let mut zero_sets: Option<Vec<ProfileZeroSet>> = None;
366 if src.zero_x_raw != 0 || src.zero_y_raw != 0 {
367 match (zero_click, src.zero_distance_m) {
368 (Some(click), Some(zero_distance_m)) if zero_distance_m > 0.0 => {
369 let click_rad = click.size / crate::adjustment::adjustment_factor(click.base);
370 let up_clicks = f64::from(src.zero_y_raw) / 1000.0;
371 let right_clicks = -f64::from(src.zero_x_raw) / 1000.0;
372 let up_m = up_clicks * click_rad * zero_distance_m;
373 let right_m = right_clicks * click_rad * zero_distance_m;
374 zero_poi_up_m = Some(up_m);
375 zero_poi_right_m = Some(right_m);
376 zero_sets = Some(vec![ProfileZeroSet {
383 name: "a7p-zero".to_string(),
384 zero_distance: None,
385 poi_up_mil: Some(-(up_clicks * click_rad * 1000.0)),
386 poi_right_mil: Some(-(right_clicks * click_rad * 1000.0)),
387 notes: Some("imported .a7p zero_x/zero_y click state".to_string()),
388 }]);
389 push(
390 "zero_x / zero_y",
391 format!(
392 "({}, {}) raw = {:.2} right / {:.2} up device clicks",
393 src.zero_x_raw, src.zero_y_raw, right_clicks, up_clicks
394 ),
395 format!(
396 "POI {:.2} cm up / {:.2} cm right at {zero_distance_m:.0} m \
397 (--zero-click {}{})",
398 up_m * 100.0,
399 right_m * 100.0,
400 click.size,
401 match click.base {
402 crate::adjustment::ClickBase::Mil => "mil",
403 crate::adjustment::ClickBase::Moa => "moa",
404 crate::adjustment::ClickBase::Smoa => "smoa",
405 },
406 ),
407 "zero_poi_up_m + zero_poi_right_m + zero_sets[a7p-zero]",
408 );
409 }
410 (Some(_), _) => {
411 report.unmapped.push((
412 "zero_x / zero_y".to_string(),
413 format!(
414 "scope zeroing click offsets ({}, {}) — the file stores no zero \
415 distance, so --zero-click cannot convert them to a POI offset",
416 src.zero_x_raw, src.zero_y_raw
417 ),
418 ));
419 }
420 (None, _) => {
423 report.unmapped.push((
424 "zero_x / zero_y".to_string(),
425 format!(
426 "scope zeroing click offsets ({}, {}) — device click size not \
427 supplied; pass --zero-click to record the profile's turret \
428 graduation and convert this offset",
429 src.zero_x_raw, src.zero_y_raw
430 ),
431 ));
432 }
433 }
434 }
435 if !src.distances_m.is_empty() {
436 report.unmapped.push((
437 "distances".to_string(),
438 format!(
439 "{} range-card entries (device UI list)",
440 src.distances_m.len()
441 ),
442 ));
443 }
444 if src.switches_count > 0 {
445 report.unmapped.push((
446 "switches".to_string(),
447 format!("{} device UI switch entries", src.switches_count),
448 ));
449 }
450 for (field, value) in [
451 ("cartridge_name", &src.cartridge_name),
452 ("caliber", &src.caliber),
453 ("short_name_top", &src.short_name_top),
454 ("short_name_bot", &src.short_name_bot),
455 ("device_uuid", &src.device_uuid),
456 ] {
457 if !value.is_empty() {
458 report
459 .unmapped
460 .push((field.to_string(), format!("\"{}\"", value.trim())));
461 }
462 }
463 if !src.user_note.trim().is_empty() {
464 report.unmapped.push((
465 "user_note".to_string(),
466 format!("\"{}\"", src.user_note.trim()),
467 ));
468 }
469 for unknown in &doc.unknown_fields {
470 report.unmapped.push((
471 format!("{} field #{}", unknown.context, unknown.number),
472 "unknown field (file newer than this importer)".to_string(),
473 ));
474 }
475
476 let profile = ProfileData {
477 name,
478 velocity: src.muzzle_velocity_mps,
479 bc,
480 mass: src.bullet_weight_gr * GRAIN_TO_GRAM,
481 diameter: src.bullet_diameter_in * IN_TO_MM,
482 drag_model: drag_model.to_string(),
483 twist_rate: Some(src.twist_in_per_turn * IN_TO_MM),
484 sight_height: Some(src.sight_height_mm),
485 zero_distance: src.zero_distance_m,
486 units: "metric".to_string(),
487 temperature: src.air_temperature_c,
488 pressure: src.air_pressure_hpa,
489 humidity: src.air_humidity_pct,
490 altitude: 0.0,
491 bullet_name: if src.bullet_name.is_empty() {
492 None
493 } else {
494 Some(src.bullet_name.clone())
495 },
496 created: Some(timestamp_string()),
497 wind_speed: None,
498 wind_direction: None,
499 shooting_angle: None,
500 auto_zero: src.zero_distance_m,
501 twist_right: Some(src.twist_right),
502 use_bc_segments: None,
508 bullet_length: Some(src.bullet_length_in * IN_TO_MM),
509 elevation_click: click_from_zero_click.clone(),
513 windage_click: click_from_zero_click,
514 bc_segments,
515 drag_curve,
516 dsf_points: None,
518 bc_reference: None,
522 pressure_reference: None,
526 density_altitude: None,
530 zero_poi_up_m,
533 zero_poi_right_m,
534 sight_offset_lateral_m: None,
536 elevation_cf: None,
538 windage_cf: None,
539 zero_sets,
541 reticle: None,
544 clicks_per_revolution: None,
548 zero_stop: None,
549 elevation_travel_up_mil: None,
550 elevation_travel_down_mil: None,
551 windage_travel_left_mil: None,
552 windage_travel_right_mil: None,
553 turret_elevation_dialed_mil: None,
554 turret_windage_dialed_mil: None,
555 hold_bound_up_mil: None,
556 hold_bound_down_mil: None,
557 hold_bound_left_mil: None,
558 hold_bound_right_mil: None,
559 };
560
561 Ok(A7pImportOutcome { profile, report })
562}