ballistics_engine/reticle_import.rs
1//! MBA-1440: import Bero's "Ventum" reticle spec into the engine's [`ReticleDescription`].
2//!
3//! A reticle drawn in Bero's Ventum tool is a single JSON object: reticle-level metadata
4//! (name, focal plane, calibration magnification, angular unit) plus a `spec` list of
5//! drawing elements. This module is the one-way transform from that authoring format into
6//! the engine's own [`ReticleDescription`], so a Ventum reticle can be hold-solved by the
7//! existing [`hold_point_in_reticle`](crate::reticle::hold_point_in_reticle) without any of the CLI/FFI/WASM surfaces having to
8//! learn a second schema. It is a converter, not new physics — every coordinate is carried
9//! straight through, only unit-normalized to milliradians and repeat-expanded.
10//!
11//! # What is kept, what is dropped
12//!
13//! The Ventum `spec` mixes *hold-bearing* marks with *decoration*:
14//!
15//! * `dot` and `tick` are the holdable marks. A `dot` becomes a [`MarkKind::Dot`]; a `tick`
16//! becomes a [`MarkKind::Hash`] whose hold point is its own `(x, y)` anchor.
17//! * `text` is a standalone label positioned in reticle coordinates. Following the schema's
18//! recommended "option (b)", each text is bound to the nearest hold-bearing mark within
19//! [`DEFAULT_TEXT_BIND_MIL`] and becomes that mark's `label`; unattached text is dropped.
20//! * `line`, `circle`, `rect`, `grid`, and any unknown future element type are pure
21//! decoration and are dropped.
22//!
23//! # Coordinate and unit conventions
24//!
25//! Ventum uses `+x = right`, `+y = down`, origin at the reticle center — identical to the
26//! engine's [`ReticleMark::right_mil`] / [`ReticleMark::down_mil`], so no sign flips are
27//! needed. All coordinates are expressed in the reticle's own `unit`; MOA input is
28//! converted to milliradians with `1 MOA = 0.2908882 mil` ([`MOA_TO_MIL`]). Auto-numbered
29//! ladder labels are NOT unit-converted — a label is the reticle-unit value it names.
30//!
31//! Confirmed by the format's author (2026-08-01): `+y` is down because a reticle is numbered
32//! downward (a 4-unit holdover is `y: 4`; a stadia mark 5 up is `y: -5`), which is plain SVG
33//! screen convention and happens to match shooter intuition.
34//!
35//! ## Arc angles (not yet consumed — recorded so the mapping is not guessed later)
36//!
37//! Ventum draws arcs as a `circle` element carrying `start`/`end` angles. Those angles are
38//! measured from 3 o'clock (`0° = +x`, right) and sweep **CLOCKWISE**:
39//!
40//! ```text
41//! 0° = right 90° = down 180° = left 270° = UP
42//! ```
43//!
44//! The one counterintuitive consequence, worth stating because it is the opposite of a
45//! compass: since `+y` is down, the TOP of the reticle is 270°, not 90°. A horseshoe opening
46//! downward is therefore `start: 200, end: 340`. This is the same system as the coordinates
47//! (`+x` right, `+y` down, 0° at 3 o'clock, clockwise), so the format never mixes conventions.
48//!
49//! A point on an arc of radius `r` about the reticle center is therefore
50//!
51//! ```text
52//! x = r * cos(θ) y = r * sin(θ) (θ in degrees, +y DOWN)
53//! ```
54//!
55//! Note the PLUS on the sine: in a y-up math convention this term is negated, and that single
56//! sign is the whole trap. Verified against the author's reference diagram — its 0/90/180/270
57//! markers and the `start: 200, end: 340` horseshoe endpoints reproduce exactly under the
58//! formula above, and the arc's 140° clockwise sweep passes through 270° (the top), leaving the
59//! gap at the bottom.
60//!
61//! This module currently drops `circle` as decoration (see above), so nothing here depends on
62//! these angles yet; the note exists so that whoever makes arcs hold-bearing does not have to
63//! re-derive the convention.
64//!
65//! # Safety
66//!
67//! The mark cap ([`crate::reticle::MAX_RETICLE_MARKS`]) is enforced *during* repeat
68//! expansion, so a hostile `repeat.n` (or a huge mirrored ladder) can never allocate an
69//! unbounded vector: expansion stops the instant the running instance count would exceed
70//! the cap, having materialized at most one instance past it. This is the same defense as
71//! the 0.31.0 reticle generator size guards.
72
73use serde::de::{self, SeqAccess, Visitor};
74use serde::{Deserialize, Deserializer};
75use std::fmt;
76
77use crate::reticle::{
78 FocalPlane, MarkKind, ReticleDescription, ReticleError, ReticleMark, MAX_RETICLE_MARKS,
79};
80
81/// Milliradians per minute of angle (`1 MOA = 0.2908882 mil`). Ventum reticles authored in
82/// MOA are converted to the engine's milliradian marks with this factor.
83pub const MOA_TO_MIL: f64 = 0.2908882;
84
85/// Largest `(x, y)` distance (in milliradians) at which a `text` element binds to a
86/// hold-bearing mark and becomes its label. Text farther than this from every mark is
87/// treated as free-floating decoration and dropped.
88pub const DEFAULT_TEXT_BIND_MIL: f64 = 1.0;
89
90/// Import a Ventum reticle spec into a [`ReticleDescription`].
91///
92/// `json` is a single Ventum reticle object (see the module documentation for the fields).
93/// The returned description is ready for [`hold_point_in_reticle`](crate::reticle::hold_point_in_reticle): its marks are in
94/// milliradians from the optical center, `focal_plane` mirrors the spec's `plane`, and
95/// `reference_magnification` carries the spec's `ref_magnification` for an SFP reticle
96/// (`1.0` for FFP, where subtensions are magnification-independent).
97///
98/// This is a pure transform: it does not itself reject an empty or all-decoration reticle
99/// (the resulting description simply carries no marks, which [`hold_point_in_reticle`](crate::reticle::hold_point_in_reticle) then
100/// reports as [`ReticleError::NoMarks`]). It DOES enforce
101/// [`crate::reticle::MAX_RETICLE_MARKS`] during expansion.
102///
103/// # Errors
104///
105/// Returns [`ReticleError::InvalidSpec`] if `json` cannot be parsed as a Ventum reticle,
106/// and [`ReticleError::TooManyMarks`] if repeat expansion would exceed the mark cap.
107pub fn import_ventum_reticle(json: &str) -> Result<ReticleDescription, ReticleError> {
108 let reticle: VentumReticle =
109 serde_json::from_str(json).map_err(|e| ReticleError::InvalidSpec(e.to_string()))?;
110
111 let scale = match reticle.unit {
112 Unit::Mil => 1.0,
113 Unit::Moa => MOA_TO_MIL,
114 };
115
116 // Expand every element's `repeat` into concrete, unit-scaled instances first, with the
117 // mark cap enforced during expansion.
118 let instances = expand_elements(&reticle.spec.0, scale)?;
119
120 // Classify: dot/tick become marks; text is collected for nearest-mark binding.
121 let mut marks: Vec<ReticleMark> = Vec::new();
122 let mut texts: Vec<TextLabel> = Vec::new();
123 for instance in instances {
124 match instance.role {
125 Role::Dot => marks.push(ReticleMark::new(instance.down, instance.right, MarkKind::Dot)),
126 Role::Tick => {
127 marks.push(ReticleMark::new(instance.down, instance.right, MarkKind::Hash))
128 }
129 Role::Text(label) => texts.push(TextLabel {
130 down: instance.down,
131 right: instance.right,
132 label,
133 }),
134 }
135 }
136
137 bind_text_labels(&mut marks, &texts);
138
139 // `ref_magnification` is meaningful only for SFP; an FFP reticle's subtensions do not
140 // depend on magnification, so we normalize its reference to 1.0 (as the engine does).
141 let reference_magnification = match reticle.plane {
142 FocalPlane::Second => reticle.ref_magnification,
143 FocalPlane::First => 1.0,
144 };
145
146 Ok(ReticleDescription {
147 name: reticle.name,
148 focal_plane: reticle.plane,
149 reference_magnification,
150 marks,
151 })
152}
153
154/// A `text` element after expansion: its position (already unit-scaled, in milliradians)
155/// and the label string it would apply to the nearest mark.
156struct TextLabel {
157 down: f64,
158 right: f64,
159 label: String,
160}
161
162/// Bind each collected text to the nearest mark within [`DEFAULT_TEXT_BIND_MIL`], setting
163/// that mark's label. Text with no mark inside the cap is dropped. When two texts bind to
164/// the same mark the later one wins.
165fn bind_text_labels(marks: &mut [ReticleMark], texts: &[TextLabel]) {
166 for text in texts {
167 let mut nearest: Option<usize> = None;
168 let mut nearest_distance = f64::INFINITY;
169 for (index, mark) in marks.iter().enumerate() {
170 let distance = ((text.right - mark.right_mil).powi(2)
171 + (text.down - mark.down_mil).powi(2))
172 .sqrt();
173 if distance < nearest_distance {
174 nearest_distance = distance;
175 nearest = Some(index);
176 }
177 }
178 if let Some(index) = nearest {
179 if nearest_distance <= DEFAULT_TEXT_BIND_MIL {
180 marks[index].label = Some(text.label.clone());
181 }
182 }
183 }
184}
185
186/// The role an expanded point instance plays once classified.
187#[derive(Debug, Clone, PartialEq)]
188enum Role {
189 Dot,
190 Tick,
191 /// A text label carrying its (possibly auto-numbered) string.
192 Text(String),
193}
194
195/// One expanded point instance: a hold-bearing mark or a text label, positioned in
196/// milliradians from center (unit scale already applied).
197#[derive(Debug, Clone, PartialEq)]
198struct ExpandedInstance {
199 /// Milliradians below the optical center.
200 down: f64,
201 /// Milliradians right of the optical center.
202 right: f64,
203 role: Role,
204}
205
206/// Which point-element kind is being expanded, borrowing the base text for `text`.
207enum PointRole<'a> {
208 Dot,
209 Tick,
210 Text(&'a str),
211}
212
213impl PointRole<'_> {
214 /// The [`Role`] for one instance. `ladder_label` is `Some` only for an auto-numbered
215 /// text ladder copy; otherwise a text keeps its element's own string.
216 fn role_with_label(&self, ladder_label: Option<String>) -> Role {
217 match self {
218 PointRole::Dot => Role::Dot,
219 PointRole::Tick => Role::Tick,
220 PointRole::Text(base) => Role::Text(ladder_label.unwrap_or_else(|| (*base).to_string())),
221 }
222 }
223
224 /// The auto-numbered label for copy `i` of a text ladder, or `None` when this is not a
225 /// labeled text ladder (dot/tick, or text without `label:true`).
226 fn ladder_label(&self, repeat: &Repeat, i: u32) -> Option<String> {
227 match self {
228 PointRole::Text(_) if repeat.label => {
229 Some(format_label_number(repeat.label_start + f64::from(i) * repeat.label_step))
230 }
231 _ => None,
232 }
233 }
234}
235
236/// Expand every element's `repeat` into concrete instances, applying `scale` to positions
237/// and enforcing the mark cap along the way. Decoration and unknown element types are
238/// dropped (not emitted, not counted).
239fn expand_elements(elements: &[Element], scale: f64) -> Result<Vec<ExpandedInstance>, ReticleError> {
240 let mut out: Vec<ExpandedInstance> = Vec::new();
241 for element in elements {
242 let (x, y, repeat, base) = match element {
243 Element::Dot { x, y, repeat } => (*x, *y, repeat.as_ref(), PointRole::Dot),
244 Element::Tick { x, y, repeat } => (*x, *y, repeat.as_ref(), PointRole::Tick),
245 Element::Text {
246 x, y, text, repeat,
247 } => (*x, *y, repeat.as_ref(), PointRole::Text(text)),
248 // Decoration and unknown future element types carry no hold and no label.
249 Element::Line {}
250 | Element::Circle {}
251 | Element::Rect {}
252 | Element::Grid {}
253 | Element::Unknown => continue,
254 };
255 expand_point(x, y, repeat, scale, base, &mut out)?;
256 }
257 Ok(out)
258}
259
260/// Expand a single point element (`dot`/`tick`/`text`) — with or without a `repeat` — into
261/// `out`, applying `scale` and enforcing the cap.
262fn expand_point(
263 x: f64,
264 y: f64,
265 repeat: Option<&Repeat>,
266 scale: f64,
267 base: PointRole<'_>,
268 out: &mut Vec<ExpandedInstance>,
269) -> Result<(), ReticleError> {
270 // Ventum uses +x = right, +y = down — the same axes the engine's marks use — so a point
271 // at (x, y) maps to (down = y, right = x), unit-scaled.
272 let Some(repeat) = repeat else {
273 // No repeat: a single instance at (x, y).
274 let role = base.role_with_label(None);
275 return push_capped(y * scale, x * scale, role, out);
276 };
277
278 for i in 0..repeat.n {
279 let offset = f64::from(i) * repeat.step;
280 // Step the chosen axis; the other axis is unchanged.
281 let (cx, cy) = match repeat.axis {
282 Axis::X => (x + offset, y),
283 Axis::Y => (x, y + offset),
284 };
285 // The coordinate on the stepped axis decides whether a mirrored copy would land on
286 // center (and so must be skipped, to avoid a duplicate).
287 let stepped = match repeat.axis {
288 Axis::X => cx,
289 Axis::Y => cy,
290 };
291
292 let down = cy * scale;
293 let right = cx * scale;
294 let role = base.role_with_label(base.ladder_label(repeat, i));
295
296 if repeat.mirror && stepped != 0.0 {
297 // Emit the axis-negated twin as well, but never a duplicate at the center. Only
298 // the stepped axis is negated. A mirrored labeled-text copy keeps the SAME label
299 // as its positive twin (magnitude convention: the "5" on the right and the "5"
300 // on the left both read 5). The written schema does not specify this, so it began
301 // as a deliberate assumption; the format's author confirmed it on 2026-08-01
302 // ("mirrored numbers are without signs"). Do not "fix" this into signed labels.
303 let (mirror_down, mirror_right) = match repeat.axis {
304 Axis::X => (down, -right),
305 Axis::Y => (-down, right),
306 };
307 push_capped(down, right, role.clone(), out)?;
308 push_capped(mirror_down, mirror_right, role, out)?;
309 } else {
310 push_capped(down, right, role, out)?;
311 }
312 }
313 Ok(())
314}
315
316/// Push one instance, rejecting the reticle the instant the running count would exceed
317/// [`MAX_RETICLE_MARKS`]. Because this checks before every push, expansion allocates at
318/// most `MAX_RETICLE_MARKS + 1` instances regardless of how large a `repeat.n` is, so a
319/// runaway ladder returns promptly instead of hanging or exhausting memory. Text instances
320/// count toward the same cap as marks, so no element kind can grow unbounded.
321fn push_capped(
322 down: f64,
323 right: f64,
324 role: Role,
325 out: &mut Vec<ExpandedInstance>,
326) -> Result<(), ReticleError> {
327 if out.len() >= MAX_RETICLE_MARKS {
328 return Err(ReticleError::TooManyMarks {
329 count: out.len() + 1,
330 max: MAX_RETICLE_MARKS,
331 });
332 }
333 out.push(ExpandedInstance { down, right, role });
334 Ok(())
335}
336
337/// Format a ladder label value so integers print without a fractional part (`5.0 -> "5"`)
338/// while fractional values keep their shortest decimal form (`5.5 -> "5.5"`).
339fn format_label_number(value: f64) -> String {
340 if value.is_finite() && value == value.round() {
341 (value as i64).to_string()
342 } else {
343 value.to_string()
344 }
345}
346
347// ---------------------------------------------------------------------------------------
348// Ventum input model (serde). Deliberately permissive: no `deny_unknown_fields`, so
349// cosmetic keys (color, width, cx/cy, size, len, orient, r, max_extent, tube_diameter,
350// notes, manufacturer, ...) are ignored rather than rejected.
351// ---------------------------------------------------------------------------------------
352
353/// A whole Ventum reticle: metadata plus its drawing `spec`.
354#[derive(Debug, Deserialize)]
355struct VentumReticle {
356 #[serde(default)]
357 name: String,
358 /// `"ffp"` / `"sfp"` — deserialized directly into the engine's [`FocalPlane`].
359 #[serde(default)]
360 plane: FocalPlane,
361 /// The magnification the subtensions are true at (SFP only).
362 #[serde(default = "default_ref_magnification")]
363 ref_magnification: f64,
364 /// The angular unit for every coordinate in `spec`.
365 #[serde(default)]
366 unit: Unit,
367 #[serde(default)]
368 spec: Spec,
369}
370
371fn default_ref_magnification() -> f64 {
372 1.0
373}
374
375/// The angular unit of a Ventum reticle's coordinates.
376#[derive(Debug, Clone, Copy, Default, Deserialize)]
377#[serde(rename_all = "snake_case")]
378enum Unit {
379 #[default]
380 Mil,
381 Moa,
382}
383
384/// The `spec` field: a JSON array of [`Element`]s, given EITHER as a JSON array OR as a
385/// string containing that array (the schema shows both forms). The custom deserializer
386/// accepts either and parses the string form with `serde_json`.
387#[derive(Debug, Default)]
388struct Spec(Vec<Element>);
389
390impl<'de> Deserialize<'de> for Spec {
391 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
392 where
393 D: Deserializer<'de>,
394 {
395 struct SpecVisitor;
396
397 impl<'de> Visitor<'de> for SpecVisitor {
398 type Value = Spec;
399
400 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
401 f.write_str("a JSON array of reticle elements, or a string containing that array")
402 }
403
404 fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
405 where
406 E: de::Error,
407 {
408 let elements: Vec<Element> =
409 serde_json::from_str(v).map_err(de::Error::custom)?;
410 Ok(Spec(elements))
411 }
412
413 fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
414 where
415 A: SeqAccess<'de>,
416 {
417 let elements =
418 Vec::<Element>::deserialize(de::value::SeqAccessDeserializer::new(seq))?;
419 Ok(Spec(elements))
420 }
421 }
422
423 deserializer.deserialize_any(SpecVisitor)
424 }
425}
426
427/// One drawing element, internally tagged on `"type"`. Unknown cosmetic fields on each
428/// variant are ignored; unknown element *types* map to [`Element::Unknown`].
429#[derive(Debug, Deserialize)]
430#[serde(tag = "type", rename_all = "snake_case")]
431enum Element {
432 /// A filled dot (hold-bearing). `r` and any other cosmetic fields are ignored.
433 Dot {
434 x: f64,
435 y: f64,
436 #[serde(default)]
437 repeat: Option<Repeat>,
438 },
439 /// A short hash mark (hold-bearing); its hold point is its `(x, y)` anchor. `len` and
440 /// `orient` are ignored.
441 Tick {
442 x: f64,
443 y: f64,
444 #[serde(default)]
445 repeat: Option<Repeat>,
446 },
447 /// A standalone text label positioned in reticle coordinates. `size` is ignored.
448 Text {
449 x: f64,
450 y: f64,
451 text: String,
452 #[serde(default)]
453 repeat: Option<Repeat>,
454 },
455 /// Decoration — dropped. Its geometry fields are ignored (empty struct variant so
456 /// serde skips every field).
457 Line {},
458 /// Decoration — dropped.
459 Circle {},
460 /// Decoration — dropped.
461 Rect {},
462 /// Decoration — dropped.
463 Grid {},
464 /// Any unknown future element type — dropped.
465 #[serde(other)]
466 Unknown,
467}
468
469/// The `repeat` operator: stamp `n` copies of an element along one axis, optionally
470/// mirrored, optionally auto-numbering a text ladder's labels.
471#[derive(Debug, Deserialize)]
472struct Repeat {
473 /// Which axis to step along.
474 axis: Axis,
475 /// Spacing between copies, in the reticle's unit.
476 step: f64,
477 /// Number of copies.
478 n: u32,
479 /// When true, also emit the axis-negated copy (a `±` symmetric ladder), except at the
480 /// center.
481 #[serde(default)]
482 mirror: bool,
483 /// (text only) When true, rewrite each copy's label to `label_start + i*label_step`.
484 #[serde(default)]
485 label: bool,
486 /// (text ladder) The value of the first copy's auto-number.
487 #[serde(rename = "labelStart", default)]
488 label_start: f64,
489 /// (text ladder) The auto-number increment per copy.
490 #[serde(rename = "labelStep", default = "default_label_step")]
491 label_step: f64,
492}
493
494fn default_label_step() -> f64 {
495 1.0
496}
497
498/// The axis a [`Repeat`] steps along.
499#[derive(Debug, Clone, Copy, Deserialize)]
500#[serde(rename_all = "snake_case")]
501enum Axis {
502 X,
503 Y,
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::reticle::hold_point_in_reticle;
510
511 /// Bero's exact MBR dot-tree spec (the array form), as three `dot` rows each stamped by
512 /// a horizontal mirrored `repeat`.
513 const MBR_SPEC: &str = r#"[{"type":"dot","y":4,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":9,"mirror":true}},{"type":"dot","y":8,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":13,"mirror":true}},{"type":"dot","y":12,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":17,"mirror":true}}]"#;
514
515 /// Collect the `right_mil` values of every mark on a given `down_mil` row, sorted.
516 fn row_rights(desc: &ReticleDescription, down: f64) -> Vec<f64> {
517 let mut rights: Vec<f64> = desc
518 .marks
519 .iter()
520 .filter(|m| m.down_mil == down)
521 .map(|m| m.right_mil)
522 .collect();
523 rights.sort_by(|a, b| a.partial_cmp(b).unwrap());
524 rights
525 }
526
527 /// The set `{±1, ±2, ..., ±max}`, sorted ascending — the expected spread of one MBR row.
528 fn symmetric_spread(max: i32) -> Vec<f64> {
529 let mut v: Vec<f64> = Vec::new();
530 for k in 1..=max {
531 v.push(-f64::from(k));
532 }
533 for k in 1..=max {
534 v.push(f64::from(k));
535 }
536 v.sort_by(|a, b| a.partial_cmp(b).unwrap());
537 v
538 }
539
540 fn assert_mbr(desc: &ReticleDescription) {
541 // 78 marks: row y=4 spans ±1..±9 (18), y=8 spans ±1..±13 (26), y=12 spans ±1..±17 (34).
542 assert_eq!(desc.marks.len(), 78, "MBR expands to 78 holdable marks");
543 assert_eq!(row_rights(desc, 4.0), symmetric_spread(9), "row down=4 -> ±1..±9");
544 assert_eq!(row_rights(desc, 8.0), symmetric_spread(13), "row down=8 -> ±1..±13");
545 assert_eq!(row_rights(desc, 12.0), symmetric_spread(17), "row down=12 -> ±1..±17");
546 assert_eq!(row_rights(desc, 4.0).len(), 18);
547 assert_eq!(row_rights(desc, 8.0).len(), 26);
548 assert_eq!(row_rights(desc, 12.0).len(), 34);
549 // Every mark is a dot, none sits on the vertical axis, and none exceeds its row span.
550 assert!(desc.marks.iter().all(|m| m.kind == MarkKind::Dot), "all MBR marks are dots");
551 assert!(
552 desc.marks.iter().all(|m| m.right_mil != 0.0),
553 "no MBR mark lands on the center line"
554 );
555 assert!(
556 row_rights(desc, 4.0).iter().all(|&r| r.abs() <= 9.0),
557 "row down=4 has nothing beyond ±9"
558 );
559 }
560
561 #[test]
562 fn mbr_array_form_expands_to_the_full_dot_tree() {
563 let json = format!(r#"{{"name":"MBR","plane":"ffp","unit":"mil","spec":{MBR_SPEC}}}"#);
564 let desc = import_ventum_reticle(&json).unwrap();
565 assert_eq!(desc.name, "MBR");
566 assert_eq!(desc.focal_plane, FocalPlane::First);
567 assert_mbr(&desc);
568 }
569
570 #[test]
571 fn mbr_string_form_parses_identically_to_the_array_form() {
572 // `spec` given as a STRING containing the JSON array.
573 let string_form = serde_json::json!({
574 "name": "MBR", "plane": "ffp", "unit": "mil", "spec": MBR_SPEC
575 })
576 .to_string();
577 let array_form = format!(r#"{{"name":"MBR","plane":"ffp","unit":"mil","spec":{MBR_SPEC}}}"#);
578
579 let from_string = import_ventum_reticle(&string_form).unwrap();
580 let from_array = import_ventum_reticle(&array_form).unwrap();
581
582 assert_mbr(&from_string);
583 assert_eq!(
584 from_string, from_array,
585 "the string-wrapped spec must import identically to the array spec"
586 );
587 }
588
589 #[test]
590 fn sfp_carries_reference_magnification_but_ffp_normalizes_it() {
591 let sfp = import_ventum_reticle(
592 r#"{"name":"SE","plane":"sfp","ref_magnification":6,"unit":"mil",
593 "spec":[{"type":"dot","x":0,"y":1}]}"#,
594 )
595 .unwrap();
596 assert_eq!(sfp.focal_plane, FocalPlane::Second);
597 assert_eq!(sfp.reference_magnification, 6.0);
598
599 // An FFP reticle ignores any ref_magnification and normalizes to 1.0.
600 let ffp = import_ventum_reticle(
601 r#"{"name":"F","plane":"ffp","ref_magnification":6,"unit":"mil",
602 "spec":[{"type":"dot","x":0,"y":1}]}"#,
603 )
604 .unwrap();
605 assert_eq!(ffp.focal_plane, FocalPlane::First);
606 assert_eq!(ffp.reference_magnification, 1.0);
607 }
608
609 #[test]
610 fn moa_coordinates_convert_to_milliradians() {
611 let desc = import_ventum_reticle(
612 r#"{"name":"M","unit":"moa","spec":[{"type":"dot","x":0,"y":2}]}"#,
613 )
614 .unwrap();
615 assert_eq!(desc.marks.len(), 1);
616 assert!(
617 (desc.marks[0].down_mil - 2.0 * MOA_TO_MIL).abs() < 1e-6,
618 "2 MOA -> {} mil (~0.581776)",
619 desc.marks[0].down_mil
620 );
621 }
622
623 #[test]
624 fn text_binds_to_the_nearest_mark_and_far_text_is_dropped() {
625 let desc = import_ventum_reticle(
626 r#"{"name":"T","unit":"mil","spec":[
627 {"type":"tick","x":0,"y":5},
628 {"type":"text","x":0.5,"y":5,"text":"5"},
629 {"type":"text","x":10,"y":10,"text":"far"}
630 ]}"#,
631 )
632 .unwrap();
633 // One tick -> one mark; the nearby "5" binds, the far "far" is dropped.
634 assert_eq!(desc.marks.len(), 1);
635 assert_eq!(desc.marks[0].down_mil, 5.0);
636 assert_eq!(desc.marks[0].kind, MarkKind::Hash);
637 assert_eq!(desc.marks[0].label.as_deref(), Some("5"));
638 assert!(
639 desc.marks.iter().all(|m| m.label.as_deref() != Some("far")),
640 "distant text must not bind"
641 );
642 }
643
644 #[test]
645 fn mirror_never_duplicates_a_center_mark() {
646 // A mirrored ladder whose stepped coordinate is 0 emits a single mark (no twin).
647 let center = import_ventum_reticle(
648 r#"{"name":"C","unit":"mil","spec":[
649 {"type":"tick","x":0,"y":1,"repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
650 ]}"#,
651 )
652 .unwrap();
653 assert_eq!(center.marks.len(), 1, "no duplicate at the center");
654 assert_eq!(center.marks[0].right_mil, 0.0);
655
656 // Off center, the mirror emits both ± copies.
657 let off = import_ventum_reticle(
658 r#"{"name":"O","unit":"mil","spec":[
659 {"type":"tick","x":1,"y":1,"repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
660 ]}"#,
661 )
662 .unwrap();
663 assert_eq!(off.marks.len(), 2, "mirror of an off-center mark yields ±1");
664 let mut rights: Vec<f64> = off.marks.iter().map(|m| m.right_mil).collect();
665 rights.sort_by(|a, b| a.partial_cmp(b).unwrap());
666 assert_eq!(rights, vec![-1.0, 1.0]);
667 }
668
669 #[test]
670 fn text_ladder_auto_numbers_each_copy() {
671 // Factored expander: a labeled text ladder renumbers each copy from labelStart by
672 // labelStep (defaults 0 and 1).
673 let elements = vec![Element::Text {
674 x: 0.0,
675 y: 1.0,
676 text: "base".to_string(),
677 repeat: Some(Repeat {
678 axis: Axis::Y,
679 step: 1.0,
680 n: 3,
681 mirror: false,
682 label: true,
683 label_start: 0.0,
684 label_step: 1.0,
685 }),
686 }];
687 let instances = expand_elements(&elements, 1.0).unwrap();
688 assert_eq!(
689 instances,
690 vec![
691 ExpandedInstance { down: 1.0, right: 0.0, role: Role::Text("0".to_string()) },
692 ExpandedInstance { down: 2.0, right: 0.0, role: Role::Text("1".to_string()) },
693 ExpandedInstance { down: 3.0, right: 0.0, role: Role::Text("2".to_string()) },
694 ]
695 );
696 }
697
698 #[test]
699 fn label_numbers_print_integers_without_a_decimal() {
700 assert_eq!(format_label_number(5.0), "5");
701 assert_eq!(format_label_number(0.0), "0");
702 assert_eq!(format_label_number(-3.0), "-3");
703 assert_eq!(format_label_number(5.5), "5.5");
704 }
705
706 #[test]
707 fn a_runaway_repeat_is_capped_promptly() {
708 let json = r#"{"name":"X","unit":"mil","spec":[
709 {"type":"dot","x":0,"y":1,"repeat":{"axis":"x","step":1,"n":100000}}
710 ]}"#;
711 // Must reject with TooManyMarks without materializing 100000 marks (the cap is
712 // enforced during expansion, so this returns immediately).
713 assert!(matches!(
714 import_ventum_reticle(json),
715 Err(ReticleError::TooManyMarks { .. })
716 ));
717 }
718
719 #[test]
720 fn malformed_json_reports_invalid_spec() {
721 assert!(matches!(
722 import_ventum_reticle("{not json"),
723 Err(ReticleError::InvalidSpec(_))
724 ));
725 // A `spec` string that is not itself a JSON array is also an invalid spec.
726 assert!(matches!(
727 import_ventum_reticle(r#"{"name":"X","spec":"not-an-array"}"#),
728 Err(ReticleError::InvalidSpec(_))
729 ));
730 }
731
732 #[test]
733 fn decoration_and_unknown_elements_are_dropped() {
734 let desc = import_ventum_reticle(
735 r#"{"name":"D","unit":"mil","spec":[
736 {"type":"line","x1":-5,"y1":0,"x2":5,"y2":0,"width":0.05},
737 {"type":"circle","x":0,"y":0,"r":1.0},
738 {"type":"rect","x":-1,"y":-1,"w":2,"h":2},
739 {"type":"grid","x0":-5,"y0":-5,"x1":5,"y1":5,"step":1},
740 {"type":"future_shape","x":0,"y":0},
741 {"type":"dot","x":0,"y":2,"color":"red","width":0.1}
742 ]}"#,
743 )
744 .unwrap();
745 // Only the dot survives; every decoration and the unknown type are dropped.
746 assert_eq!(desc.marks.len(), 1);
747 assert_eq!(desc.marks[0].down_mil, 2.0);
748 assert_eq!(desc.marks[0].kind, MarkKind::Dot);
749 }
750
751 #[test]
752 fn imported_mbr_reticle_hold_solves() {
753 let json = format!(r#"{{"name":"MBR","plane":"ffp","unit":"mil","spec":{MBR_SPEC}}}"#);
754 let desc = import_ventum_reticle(&json).unwrap();
755 // (down 4, right 2) is a real MBR mark, so a matching hold lands on it.
756 let hold = hold_point_in_reticle(4.0, 2.0, 1.0, &desc).unwrap();
757 assert!(hold.nearest_mark.is_some());
758 assert!(
759 hold.nearest_mark_distance_mil < 0.5,
760 "hold should sit on/near a real mark (distance {})",
761 hold.nearest_mark_distance_mil
762 );
763 assert!(!hold.off_reticle, "a mid-tree hold is on the reticle");
764 }
765}