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//! **Nothing is dropped silently.** [`import_ventum_reticle_with_report`] returns a
24//! [`VentumImportReport`] tallying every element that produced no hold point, so a caller can
25//! say "this document drew 6 things I cannot aim with" instead of handing back a reticle that
26//! is quietly missing hold points and looks merely sparse. [`import_ventum_reticle`] is that
27//! function with the report discarded; it exists for callers that genuinely do not care.
28//!
29//! # Coordinate and unit conventions
30//!
31//! Ventum uses `+x = right`, `+y = down`, origin at the reticle center — identical to the
32//! engine's [`ReticleMark::right_mil`] / [`ReticleMark::down_mil`], so no sign flips are
33//! needed. All coordinates are expressed in the reticle's own `unit`; MOA input is
34//! converted to milliradians with `1 MOA = 0.2908882 mil` ([`MOA_TO_MIL`]). Auto-numbered
35//! ladder labels are NOT unit-converted — a label is the reticle-unit value it names.
36//!
37//! Confirmed by the format's author (2026-08-01): `+y` is down because a reticle is numbered
38//! downward (a 4-unit holdover is `y: 4`; a stadia mark 5 up is `y: -5`), which is plain SVG
39//! screen convention and happens to match shooter intuition.
40//!
41//! ## Arc angles
42//!
43//! Ventum draws arcs as a `circle` element carrying `start`/`end` angles. Those angles are
44//! measured from 3 o'clock (`0° = +x`, right) and sweep **CLOCKWISE**:
45//!
46//! ```text
47//!   0° = right      90° = down      180° = left      270° = UP
48//! ```
49//!
50//! The one counterintuitive consequence, worth stating because it is the opposite of a
51//! compass: since `+y` is down, the TOP of the reticle is 270°, not 90°. A horseshoe opening
52//! downward is therefore `start: 200, end: 340`. This is the same system as the coordinates
53//! (`+x` right, `+y` down, 0° at 3 o'clock, clockwise), so the format never mixes conventions.
54//!
55//! A point on an arc of radius `r` about the reticle center is therefore
56//!
57//! ```text
58//!   x = r * cos(θ)          y = r * sin(θ)          (θ in degrees, +y DOWN)
59//! ```
60//!
61//! Note the PLUS on the sine: in a y-up math convention this term is negated, and that single
62//! sign is the whole trap. Verified against the author's reference diagram — its 0/90/180/270
63//! markers and the `start: 200, end: 340` horseshoe endpoints reproduce exactly under the
64//! formula above, and the arc's 140° clockwise sweep passes through 270° (the top), leaving the
65//! gap at the bottom.
66//!
67//! ## Why an arc still contributes no mark (MBA-1441)
68//!
69//! A horseshoe's apex and its two tips are real aiming references on Vortex-style reticles,
70//! so "does an arc carry a hold?" has no single answer — and the Ventum format gives the
71//! document no way to say. A `circle` with a sweep is spelled identically whether it is a
72//! ranging horseshoe or a decorative ring segment, and turning every one of them into three
73//! marks would do two bad things at once: fabricate aiming points a reticle may not have, and
74//! silently change what [`hold_point_in_reticle`](crate::reticle::hold_point_in_reticle)
75//! answers for every Ventum reticle already imported since 0.32.0, since the nearest mark it
76//! reports would start snapping to invented geometry. Inventing holds out of decoration is the
77//! same sin as dropping holds without saying so.
78//!
79//! So an arc is still not a mark — but it is no longer a silence either. Each one whose
80//! geometry this module can read is resolved (using exactly the convention above, which is why
81//! the convention was recorded) into the three points a shooter could actually index on, and
82//! handed back on the report as a [`VentumArc`]: its two tips and its apex, in the engine's own
83//! `right_mil` / `down_mil`. One whose radius, sweep or center the document wrote in a form
84//! this module cannot read is counted in [`VentumImportReport::arcs_unresolved`] instead —
85//! reporting points computed from a fallback value nobody wrote would be inventing the very
86//! holds the paragraph above refuses to invent. A caller who knows their horseshoe is
87//! hold-bearing turns the resolved ones into marks with three [`ReticleMark::new`] calls and
88//! never re-derives the clockwise/`+y`-down trap; a caller who does not, at least learns the
89//! document drew something they cannot aim with.
90//!
91//! # Safety
92//!
93//! The mark cap ([`crate::reticle::MAX_RETICLE_MARKS`]) is enforced *during* repeat
94//! expansion, so a hostile `repeat.n` (or a huge mirrored ladder) can never allocate an
95//! unbounded vector: expansion stops the instant the running instance count would exceed
96//! the cap, having materialized at most one instance past it. This is the same defense as
97//! the 0.31.0 reticle generator size guards.
98
99use serde::de::{self, SeqAccess, Visitor};
100use serde::{Deserialize, Deserializer};
101use std::collections::BTreeMap;
102use std::fmt;
103
104use crate::reticle::{
105    FocalPlane, MarkKind, ReticleDescription, ReticleError, ReticleMark, MAX_RETICLE_MARKS,
106};
107
108/// Milliradians per minute of angle (`1 MOA = 0.2908882 mil`). Ventum reticles authored in
109/// MOA are converted to the engine's milliradian marks with this factor.
110pub const MOA_TO_MIL: f64 = 0.2908882;
111
112/// Largest `(x, y)` distance (in milliradians) at which a `text` element binds to a
113/// hold-bearing mark and becomes its label. Text farther than this from every mark is
114/// treated as free-floating decoration and dropped.
115pub const DEFAULT_TEXT_BIND_MIL: f64 = 1.0;
116
117/// The tag [`VentumImportReport::dropped_element_types`] uses for a `circle` that carries a
118/// sweep. The format has no `arc` element — an arc IS a `circle` with `start`/`end` — but the
119/// two are worth telling apart in a report, because only one of them might have been a hold.
120pub const ARC_TAG: &str = "arc";
121
122/// How near a mirrored arc's angles must land to the original's for the reflection to count
123/// as having mapped the arc onto itself (degrees). The comparison is of sums of the
124/// document's own angle literals, so the only error to absorb is the last bit or two of the
125/// subtraction — this is a float-equality guard, not a modelling tolerance.
126const ARC_SYMMETRY_EPSILON_DEGREES: f64 = 1e-9;
127
128/// A point on an imported arc, in the engine's own mark coordinates.
129#[derive(Debug, Clone, Copy, PartialEq, Default)]
130pub struct VentumArcPoint {
131    /// Milliradians right of the optical center (negative = left).
132    pub right_mil: f64,
133    /// Milliradians below the optical center (negative = above).
134    pub down_mil: f64,
135}
136
137/// One `circle` element that carried a sweep — a horseshoe or other arc — resolved into the
138/// points a shooter could index on, but NOT imported as marks. See the module documentation
139/// for why the decision is left to the caller.
140///
141/// Every field is in milliradians from the optical center (angles excepted, which are the
142/// document's own degrees), so
143/// `ReticleMark::new(arc.apex.down_mil, arc.apex.right_mil, MarkKind::Dot)` is all it takes to
144/// adopt one.
145#[derive(Debug, Clone, Copy, PartialEq, Default)]
146pub struct VentumArc {
147    /// The arc's center — the point its radius is measured from, NOT an aiming point.
148    pub center: VentumArcPoint,
149    /// Arc radius in milliradians.
150    pub radius_mil: f64,
151    /// The arc's `start` angle, degrees, measured from 3 o'clock and sweeping clockwise (so
152    /// 270° is the TOP of the reticle — see the module documentation).
153    ///
154    /// For an arc the document drew, this is the document's own number, reported as written —
155    /// a Ventum tool is free to write a bearing outside `[0, 360)` and this is not the place
156    /// that rewrites it. For the twin of a mirrored `repeat`, whose angles this module
157    /// computed by reflection, it is reduced into `[0, 360)` (MBA-1536).
158    pub start_degrees: f64,
159    /// The arc's `end` angle, same convention and same provenance as
160    /// [`Self::start_degrees`].
161    pub end_degrees: f64,
162    /// How far the arc sweeps clockwise from `start` to `end`, in `(0, 360)` degrees. A
163    /// `circle` whose angles describe a whole revolution is a ring, not an arc, and never
164    /// appears here.
165    pub sweep_degrees: f64,
166    /// Where the arc begins: the visible tip at `start_degrees`.
167    pub start_tip: VentumArcPoint,
168    /// The sweep midpoint — a horseshoe's apex, the closed end opposite its gap. The one
169    /// point on an arc a shooter can index without measuring.
170    pub apex: VentumArcPoint,
171    /// Where the arc ends: the visible tip at `end_degrees`.
172    pub end_tip: VentumArcPoint,
173}
174
175/// What an import could not turn into a hold point, so a sparse reticle is explained rather
176/// than merely suspicious. Returned by [`import_ventum_reticle_with_report`].
177///
178/// # How the counts are taken
179///
180/// `arc`, `circle` and `text` entries count *expanded* instances. This report resolves each
181/// arc instance's own geometry, and a mirrored pair of horseshoes is genuinely two of them; an
182/// unbound `text` is a lost LABEL rather than a lost shape, so a three-copy ladder that binds
183/// to nothing has lost three of them. The one exception is a `circle` whose `repeat` this
184/// module could not read: it is expanded as though it had none and so counts once, and
185/// [`Self::circle_repeats_unreadable`] says how many elements that happened to, so the
186/// shortfall is declared rather than hidden.
187///
188/// `line`, `rect`, `grid` and unknown types count elements *as the document writes them* — a
189/// `repeat` on one of those counts once, since expansion reads no geometry from them and the
190/// report carries none to tell a dropped shape's copies from the shape itself.
191///
192/// Pinned by `tests::a_repeat_counts_per_instance_for_text_and_once_for_a_dropped_shape`.
193#[derive(Debug, Clone, PartialEq, Default)]
194pub struct VentumImportReport {
195    /// How many elements produced no hold point — the sum of [`Self::dropped_element_types`].
196    /// Zero means the whole document is in the returned [`ReticleDescription`].
197    pub dropped_elements: usize,
198    /// Per-type counts of that drop, sorted by tag so the report is deterministic. Tags are
199    /// the format's own element types, plus [`ARC_TAG`] for a `circle` carrying a sweep and
200    /// `"unknown"` for an element type this module does not know. A `"text"` entry counts
201    /// only UNBOUND text: text that landed on a mark became its label and was not dropped.
202    pub dropped_element_types: Vec<(String, usize)>,
203    /// Every dropped arc, resolved. See [`VentumArc`] and the module documentation.
204    pub arcs: Vec<VentumArc>,
205    /// Arcs the document declared but this module could not resolve into points.
206    ///
207    /// An arc's apex and tips are built from three things — a center, a radius that is a
208    /// positive length, and a sweep — and the importer computes nothing without
209    /// all three. Anything that leaves one of them unavailable lands the arc here: a key the
210    /// document omitted, a value spelled in a type this module cannot read, or a radius that
211    /// reads perfectly well as a number and is not a length (`0`, or negative). Each of those
212    /// cases is driven through the importer by
213    /// `tests::an_arc_resolves_only_from_a_center_a_positive_radius_and_a_sweep`.
214    ///
215    /// They are the SAME loss and land here for the same reason: an arc is reported so a
216    /// shooter can copy its apex and tips onto their reticle, and a coordinate derived from a
217    /// value nobody wrote is worse than no coordinate at all. They are counted in
218    /// [`Self::dropped_element_types`] under [`ARC_TAG`] like any other arc, so `arcs.len()`
219    /// plus this equals that tally — the discrepancy is stated rather than left to be
220    /// noticed.
221    pub arcs_unresolved: usize,
222    /// How many `circle` elements declared a `repeat` this module could not read, so each was
223    /// expanded as though it had none and counted ONCE where the document may draw many.
224    ///
225    /// A circle's repeat is read leniently (see the serde section below) because a circle is
226    /// never a hold point and must not be able to fail an import. That leniency costs the
227    /// tally its accuracy for those elements, and this is the accounting for it: the count is
228    /// low by an unknown amount, and the caller is told so instead of being handed a total
229    /// that quietly is not one.
230    pub circle_repeats_unreadable: usize,
231}
232
233impl VentumImportReport {
234    /// Whether anything at all was dropped. A caller that only wants to know "should I warn
235    /// the user" asks this.
236    pub fn is_empty(&self) -> bool {
237        self.dropped_elements == 0
238    }
239
240    /// The per-type tally as `"arc x1, line x3"`, for a one-line notice. Empty string when
241    /// nothing was dropped.
242    pub fn tally(&self) -> String {
243        self.dropped_element_types
244            .iter()
245            .map(|(tag, count)| format!("{tag} x{count}"))
246            .collect::<Vec<_>>()
247            .join(", ")
248    }
249}
250
251/// Import a Ventum reticle spec into a [`ReticleDescription`].
252///
253/// `json` is a single Ventum reticle object (see the module documentation for the fields).
254/// The returned description is ready for [`hold_point_in_reticle`](crate::reticle::hold_point_in_reticle): its marks are in
255/// milliradians from the optical center, `focal_plane` mirrors the spec's `plane`, and
256/// `reference_magnification` carries the spec's `ref_magnification` for an SFP reticle
257/// (`1.0` for FFP, where subtensions are magnification-independent).
258///
259/// This is a pure transform: it does not itself reject an empty or all-decoration reticle
260/// (the resulting description simply carries no marks, which [`hold_point_in_reticle`](crate::reticle::hold_point_in_reticle) then
261/// reports as [`ReticleError::NoMarks`]). It DOES enforce
262/// [`crate::reticle::MAX_RETICLE_MARKS`] during expansion.
263///
264/// Use [`import_ventum_reticle_with_report`] when you want to know what the document drew
265/// that could not become a hold point — which is most callers, since a reticle missing its
266/// arcs looks sparse rather than wrong.
267///
268/// # Errors
269///
270/// Returns [`ReticleError::InvalidSpec`] if `json` cannot be parsed as a Ventum reticle,
271/// and [`ReticleError::TooManyMarks`] if repeat expansion would exceed the mark cap.
272pub fn import_ventum_reticle(json: &str) -> Result<ReticleDescription, ReticleError> {
273    import_ventum_reticle_with_report(json).map(|(description, _)| description)
274}
275
276/// Import a Ventum reticle spec, also returning a [`VentumImportReport`] of everything the
277/// hold/decoration split left behind.
278///
279/// Identical to [`import_ventum_reticle`] in every other respect; that function is this one
280/// with the report discarded. The description is byte-for-byte what it has returned since
281/// 0.32.0 — resolving arcs added a report, not a mark.
282///
283/// # Errors
284///
285/// As [`import_ventum_reticle`].
286pub fn import_ventum_reticle_with_report(
287    json: &str,
288) -> Result<(ReticleDescription, VentumImportReport), ReticleError> {
289    let reticle: VentumReticle =
290        serde_json::from_str(json).map_err(|e| ReticleError::InvalidSpec(e.to_string()))?;
291
292    let scale = match reticle.unit {
293        Unit::Mil => 1.0,
294        Unit::Moa => MOA_TO_MIL,
295    };
296
297    // Expand every element's `repeat` into concrete, unit-scaled instances first, with the
298    // mark cap enforced during expansion.
299    let mut dropped: BTreeMap<&'static str, usize> = BTreeMap::new();
300    let mut circle_repeats_unreadable = 0usize;
301    let instances = expand_elements(
302        &reticle.spec.0,
303        scale,
304        &mut dropped,
305        &mut circle_repeats_unreadable,
306    )?;
307
308    // Classify: dot/tick become marks; text is collected for nearest-mark binding; a circle
309    // is decoration that gets counted, and an arc is decoration that gets counted AND
310    // resolved.
311    let mut marks: Vec<ReticleMark> = Vec::new();
312    let mut texts: Vec<TextLabel> = Vec::new();
313    let mut arcs: Vec<VentumArc> = Vec::new();
314    let mut arcs_unresolved = 0usize;
315    for instance in instances {
316        match instance.role {
317            Role::Dot => marks.push(ReticleMark::new(
318                instance.down,
319                instance.right,
320                MarkKind::Dot,
321            )),
322            Role::Tick => marks.push(ReticleMark::new(
323                instance.down,
324                instance.right,
325                MarkKind::Hash,
326            )),
327            Role::Text(label) => texts.push(TextLabel {
328                down: instance.down,
329                right: instance.right,
330                label,
331            }),
332            Role::Circle(circle) => {
333                let tag = if circle.is_arc() { ARC_TAG } else { "circle" };
334                *dropped.entry(tag).or_insert(0) += 1;
335                if tag == ARC_TAG {
336                    match circle.resolve(instance.down, instance.right) {
337                        Some(arc) => arcs.push(arc),
338                        None => arcs_unresolved += 1,
339                    }
340                }
341            }
342        }
343    }
344
345    // Text that found no mark inside the bind radius is dropped, and that is a lost label,
346    // not a lost shape — count it here, where binding is what decides it.
347    let unbound = bind_text_labels(&mut marks, &texts);
348    if unbound > 0 {
349        *dropped.entry("text").or_insert(0) += unbound;
350    }
351
352    // `ref_magnification` is meaningful only for SFP; an FFP reticle's subtensions do not
353    // depend on magnification, so we normalize its reference to 1.0 (as the engine does).
354    let reference_magnification = match reticle.plane {
355        FocalPlane::Second => reticle.ref_magnification,
356        FocalPlane::First => 1.0,
357    };
358
359    let report = VentumImportReport {
360        dropped_elements: dropped.values().sum(),
361        dropped_element_types: dropped
362            .into_iter()
363            .map(|(tag, count)| (tag.to_string(), count))
364            .collect(),
365        arcs,
366        arcs_unresolved,
367        circle_repeats_unreadable,
368    };
369
370    Ok((
371        ReticleDescription {
372            name: reticle.name,
373            focal_plane: reticle.plane,
374            reference_magnification,
375            marks,
376        },
377        report,
378    ))
379}
380
381/// A `text` element after expansion: its position (already unit-scaled, in milliradians)
382/// and the label string it would apply to the nearest mark.
383struct TextLabel {
384    down: f64,
385    right: f64,
386    label: String,
387}
388
389/// Bind each collected text to the nearest mark within [`DEFAULT_TEXT_BIND_MIL`], setting
390/// that mark's label. Text with no mark inside the cap is dropped. When two texts bind to
391/// the same mark the later one wins.
392///
393/// Returns how many texts bound to nothing, so MBA-1441's report can say a label was lost
394/// rather than leave a mark looking as though the document never named it. A reticle with no
395/// marks at all drops every text, which is the same answer by a shorter route.
396fn bind_text_labels(marks: &mut [ReticleMark], texts: &[TextLabel]) -> usize {
397    let mut unbound = 0usize;
398    for text in texts {
399        let mut nearest: Option<usize> = None;
400        let mut nearest_distance = f64::INFINITY;
401        for (index, mark) in marks.iter().enumerate() {
402            let distance = ((text.right - mark.right_mil).powi(2)
403                + (text.down - mark.down_mil).powi(2))
404            .sqrt();
405            if distance < nearest_distance {
406                nearest_distance = distance;
407                nearest = Some(index);
408            }
409        }
410        match nearest {
411            Some(index) if nearest_distance <= DEFAULT_TEXT_BIND_MIL => {
412                marks[index].label = Some(text.label.clone());
413            }
414            _ => unbound += 1,
415        }
416    }
417    unbound
418}
419
420/// The role an expanded point instance plays once classified.
421#[derive(Debug, Clone, PartialEq)]
422enum Role {
423    Dot,
424    Tick,
425    /// A text label carrying its (possibly auto-numbered) string.
426    Text(String),
427    /// A `circle` element, with the sweep that makes it an arc when it has one. Not a hold —
428    /// carried through expansion so MBA-1441's report can count and resolve each instance.
429    Circle(CircleShape),
430}
431
432impl Role {
433    /// This role as it appears in a [`Repeat`]'s mirrored twin.
434    ///
435    /// Only an arc changes: mirroring reflects its angles AND reverses its direction of
436    /// travel, so the clockwise sweep `start -> end` becomes the clockwise sweep
437    /// `f(end) -> f(start)`. Reflecting about the vertical axis (a stepped `x`, so
438    /// `right -> -right`) maps `theta -> 180 - theta`; about the horizontal axis (a stepped
439    /// `y`, so `down -> -down`) it maps `theta -> -theta`. Both fall straight out of
440    /// `(cos t, sin t)` under the module's `+y`-down convention; neither is a guess.
441    ///
442    /// Dots, ticks and text are unchanged, which is what keeps mirrored ladder labels
443    /// unsigned (see [`expand_point`]).
444    fn mirrored(&self, axis: Axis) -> Role {
445        match self {
446            Role::Circle(circle) => Role::Circle(circle.mirrored(axis)),
447            other => other.clone(),
448        }
449    }
450}
451
452/// A `circle` element's geometry, already unit-scaled. The center is carried by the
453/// [`ExpandedInstance`] around this, so only the radius and the optional sweep live here.
454#[derive(Debug, Clone, Copy, PartialEq)]
455struct CircleShape {
456    /// Radius in milliradians, or `None` when the element omitted `r` or wrote one this
457    /// module could not read as a number. A number it COULD read is kept exactly as written,
458    /// `0` and negatives included; [`Self::resolve`] is the one place a radius is judged as a
459    /// length. Either way the element is still counted; it just cannot be resolved.
460    radius_mil: Option<f64>,
461    /// The document's `start`/`end` angles in degrees, when it declared both AS NUMBERS.
462    /// `None` is a plain ring, which is decoration under any reading and gets no [`VentumArc`]
463    /// — unless [`Self::sweep_unreadable`] says the document declared a sweep this module
464    /// could not read, in which case it is an arc whose angles are lost, not a ring.
465    /// Both members are finite by construction ([`Cosmetic::Value`]).
466    angles: Option<(f64, f64)>,
467    /// The document gave BOTH `start` and `end` a value, and at least one of those values is
468    /// not a number.
469    ///
470    /// This is the difference between "no sweep was declared" and "a sweep was declared and I
471    /// cannot read it", and collapsing the two is how a horseshoe turns into a ring in
472    /// silence. One angle alone still describes no sweep — the format needs both — so a
473    /// circle that declares only `start` is a ring whatever type that `start` has.
474    ///
475    /// "Gave a value" excludes `null`, which this model reads as absence everywhere
476    /// ([`Cosmetic::Absent`]). So `{"start": null, "end": "340deg"}` is a ring too: the
477    /// document supplied one angle, not two, and one angle is no sweep however it is spelled.
478    /// Pinned by `tests::null_is_read_as_absence_not_as_a_bad_value`.
479    sweep_unreadable: bool,
480    /// The document wrote `x`/`cx` or `y`/`cy` as something that is not a number, so the
481    /// center this shape is resolved about is the fallback origin rather than the one the
482    /// document meant. On an arc it would put the reported apex and tips somewhere nobody
483    /// wrote, so it makes the arc unresolved. A ring is never resolved and so loses no points
484    /// to it — but it does lose the mirror dedupe, which cannot prove a twin is a duplicate
485    /// about a center it does not have, and is then counted as the two shapes it drew.
486    center_unreadable: bool,
487}
488
489/// Reduce an angle in degrees into `[0, 360)`, the range a [`VentumArc`]'s reported angles
490/// are measured in. A non-finite input is returned unchanged: it is not an angle, and
491/// `rem_euclid` would turn it into a different non-finite value rather than a usable one.
492fn normalize_degrees(degrees: f64) -> f64 {
493    if !degrees.is_finite() {
494        return degrees;
495    }
496    let reduced = degrees.rem_euclid(360.0);
497    // `rem_euclid` rounds, so an input a hair below zero can come back as exactly 360.0 —
498    // the one value this half-open range excludes. Pinned by `normalize_degrees_lands_in_range`.
499    if reduced >= 360.0 {
500        0.0
501    } else {
502        reduced
503    }
504}
505
506impl CircleShape {
507    /// Whether this element is an ARC — a `circle` the document gave a sweep — rather than a
508    /// plain ring. True both for a sweep this module read and for one it could not, because
509    /// the tag a document earns must not depend on whether its angles happened to be spelled
510    /// in a type this module understands.
511    fn is_arc(&self) -> bool {
512        self.sweep_degrees().is_some() || self.sweep_unreadable
513    }
514
515    /// How far this circle sweeps clockwise, in `(0, 360)` degrees, or `None` when it is a
516    /// full ring — either because it declared no angles, or because the angles it declared
517    /// describe a whole revolution (`start == end`, or a multiple of 360 apart).
518    fn sweep_degrees(&self) -> Option<f64> {
519        let (start, end) = self.angles?;
520        if !start.is_finite() || !end.is_finite() {
521            return None;
522        }
523        let sweep = (end - start).rem_euclid(360.0);
524        // A zero remainder is a closed circle drawn as an arc, not a degenerate arc.
525        (sweep > 0.0).then_some(sweep)
526    }
527
528    /// Whether reflecting this shape about `axis` maps it onto ITSELF, so a mirrored twin
529    /// sharing its center would be a duplicate rather than a second shape.
530    ///
531    /// This is the arc's half of [`expand_point`]'s center dedupe. For a mark, "the mirror
532    /// landed on me" is the whole test; for an arc it is only half of it, because reflection
533    /// also reworks the angles ([`Role::mirrored`]). Given geometry this module can read — the
534    /// body below is where unreadable geometry is turned down, since it proves nothing either
535    /// way — a ring maps onto itself under any reflection through its own center, and a swept
536    /// arc does so exactly when its two endpoints
537    /// swap into each other: reflection about the vertical axis sends `theta -> 180 - theta`,
538    /// so it needs `start + end == 180`; about the horizontal axis it sends `theta -> -theta`,
539    /// so it needs `start + end == 0` — both modulo a full revolution. A horseshoe centered on
540    /// the vertical axis (`start: 200, end: 340`, sum 540 ≡ 180) is symmetric about it and is
541    /// deduped; the same horseshoe mirrored vertically opens the other way and is not.
542    fn is_reflection_of_itself(&self, axis: Axis) -> bool {
543        // Geometry the document wrote and this module could not read answers NOTHING here.
544        // The dedupe drops a twin only when it can prove the twin is a duplicate, and with
545        // the angles or the center unreadable there is no proof either way — so the twin is
546        // kept and the document is credited with the two shapes it drew. (On an arc both
547        // copies then land in `arcs_unresolved`, where the caller is told the geometry was
548        // lost; a ring is only ever counted, so for one this is the whole of the difference.)
549        // Guessing "duplicate" here would undercount in silence, which is the failure this
550        // whole report exists to end.
551        if self.sweep_unreadable || self.center_unreadable {
552            return false;
553        }
554        // A ring — no angles, or angles spanning a whole revolution — is its own reflection.
555        let Some((start, end)) = self.angles.filter(|_| self.sweep_degrees().is_some()) else {
556            return true;
557        };
558        let target = match axis {
559            Axis::X => 180.0,
560            Axis::Y => 0.0,
561        };
562        let offset = (start + end - target).rem_euclid(360.0);
563        // Distance to the nearest multiple of 360, so 0 and 360 are both "no rotation".
564        offset.min(360.0 - offset) <= ARC_SYMMETRY_EPSILON_DEGREES
565    }
566
567    /// This shape reflected for a mirrored [`Repeat`] twin. See [`Role::mirrored`] for the
568    /// derivation; note the swap, which is the direction reversal.
569    ///
570    /// MBA-1536: the reflected angles are reduced into `[0, 360)` before they are stored,
571    /// because they are reported. `180 - theta` and `-theta` both run negative — the twin of a
572    /// `start: 290, end: 70` horseshoe reflects to `(110, -110)`, which the CLI printed as
573    /// `arc 110--110 deg` — and a [`VentumArc`] angle is documented as degrees CLOCKWISE from
574    /// 3 o'clock, a direction a negative number does not describe. Reduction is the identity
575    /// on everything computed from these angles: every use is either a sine/cosine or a
576    /// difference taken `rem_euclid(360)` ([`Self::sweep_degrees`], [`Self::resolve`]), and
577    /// [`Self::is_reflection_of_itself`] is a `rem_euclid(360)` congruence on `start + end`.
578    /// The reported numbers are the only thing that moves, which is the point.
579    fn mirrored(&self, axis: Axis) -> CircleShape {
580        let angles = self.angles.map(|(start, end)| match axis {
581            Axis::X => (
582                normalize_degrees(180.0 - end),
583                normalize_degrees(180.0 - start),
584            ),
585            Axis::Y => (normalize_degrees(-end), normalize_degrees(-start)),
586        });
587        CircleShape {
588            radius_mil: self.radius_mil,
589            angles,
590            sweep_unreadable: self.sweep_unreadable,
591            center_unreadable: self.center_unreadable,
592        }
593    }
594
595    /// Resolve this circle, centered at `(down, right)` milliradians, into the arc points a
596    /// caller could adopt as holds. `None` when it is a ring, or when the element declared
597    /// geometry this module could not read — the caller counts those separately rather than
598    /// reporting points derived from a fallback the document never wrote.
599    fn resolve(&self, down: f64, right: f64) -> Option<VentumArc> {
600        // A center the document declared and this module could not read is exactly as fatal
601        // to the answer as a missing radius: both feed the same `point()` below, and a
602        // coordinate computed from a value nobody wrote is a hold this report would be
603        // inventing. That half of this guard is the one that does the work.
604        //
605        // The `sweep_unreadable` half decides nothing on its own — an unreadable angle leaves
606        // `angles` empty, so `sweep_degrees()?` on the next line would bail regardless
607        // (removing it fails no test; that was checked). It is stated anyway because the two
608        // flags mean the same thing to a reader, and because `is_arc` above genuinely depends
609        // on the distinction.
610        if self.sweep_unreadable || self.center_unreadable {
611            return None;
612        }
613        let sweep = self.sweep_degrees()?;
614        let radius = self.radius_mil?;
615        if !radius.is_finite() || radius <= 0.0 || !down.is_finite() || !right.is_finite() {
616            return None;
617        }
618        let (start, end) = self.angles?;
619
620        // x = r*cos(theta), y = r*sin(theta) about the center, degrees, +y DOWN — the module
621        // documentation's formula, verified against the format author's reference diagram.
622        // The PLUS on the sine is the whole trap: a y-up convention negates it.
623        let point = |degrees: f64| {
624            let radians = degrees.to_radians();
625            VentumArcPoint {
626                right_mil: right + radius * radians.cos(),
627                down_mil: down + radius * radians.sin(),
628            }
629        };
630
631        Some(VentumArc {
632            center: VentumArcPoint {
633                right_mil: right,
634                down_mil: down,
635            },
636            radius_mil: radius,
637            start_degrees: start,
638            end_degrees: end,
639            sweep_degrees: sweep,
640            start_tip: point(start),
641            // Halfway along the sweep, travelling clockwise from `start` — for a horseshoe,
642            // the closed end opposite its gap.
643            apex: point(start + sweep / 2.0),
644            end_tip: point(end),
645        })
646    }
647}
648
649/// One expanded point instance: a hold-bearing mark or a text label, positioned in
650/// milliradians from center (unit scale already applied).
651#[derive(Debug, Clone, PartialEq)]
652struct ExpandedInstance {
653    /// Milliradians below the optical center.
654    down: f64,
655    /// Milliradians right of the optical center.
656    right: f64,
657    role: Role,
658}
659
660/// Which point-element kind is being expanded, borrowing the base text for `text`.
661enum PointRole<'a> {
662    Dot,
663    Tick,
664    Text(&'a str),
665    Circle(CircleShape),
666}
667
668impl PointRole<'_> {
669    /// The [`Role`] for one instance. `ladder_label` is `Some` only for an auto-numbered
670    /// text ladder copy; otherwise a text keeps its element's own string.
671    fn role_with_label(&self, ladder_label: Option<String>) -> Role {
672        match self {
673            PointRole::Dot => Role::Dot,
674            PointRole::Tick => Role::Tick,
675            PointRole::Text(base) => {
676                Role::Text(ladder_label.unwrap_or_else(|| (*base).to_string()))
677            }
678            PointRole::Circle(shape) => Role::Circle(*shape),
679        }
680    }
681
682    /// The auto-numbered label for copy `i` of a text ladder, or `None` when this is not a
683    /// labeled text ladder (dot/tick, or text without `label:true`).
684    fn ladder_label(&self, repeat: &Repeat, i: u32) -> Option<String> {
685        match self {
686            PointRole::Text(_) if repeat.label => Some(format_label_number(
687                repeat.label_start + f64::from(i) * repeat.label_step,
688            )),
689            _ => None,
690        }
691    }
692}
693
694/// Expand every element's `repeat` into concrete instances, applying `scale` to positions
695/// and enforcing the mark cap along the way.
696///
697/// MBA-1441: nothing is dropped without a tally any more. `line`/`rect`/`grid`/unknown carry
698/// no geometry this module reads, so each is counted once into `dropped` and skipped;
699/// `circle` goes through the expander like a point element, because an arc's resolved apex
700/// and tips are per-instance and a mirrored pair of horseshoes is two distinct arcs.
701///
702/// `circle_repeats_unreadable` counts the circles whose `repeat` this module could not read
703/// and therefore expanded as though absent, so the caller learns the tally is low rather than
704/// being handed a total that quietly is not one.
705fn expand_elements(
706    elements: &[Element],
707    scale: f64,
708    dropped: &mut BTreeMap<&'static str, usize>,
709    circle_repeats_unreadable: &mut usize,
710) -> Result<Vec<ExpandedInstance>, ReticleError> {
711    let mut out: Vec<ExpandedInstance> = Vec::new();
712    for element in elements {
713        let (x, y, repeat, base) = match element {
714            Element::Dot { x, y, repeat } => (*x, *y, repeat.as_ref(), PointRole::Dot),
715            Element::Tick { x, y, repeat } => (*x, *y, repeat.as_ref(), PointRole::Tick),
716            Element::Text { x, y, text, repeat } => {
717                (*x, *y, repeat.as_ref(), PointRole::Text(text))
718            }
719            Element::Circle(CircleFields {
720                x,
721                y,
722                r,
723                start,
724                end,
725                repeat,
726            }) => {
727                if repeat.is_unreadable() {
728                    *circle_repeats_unreadable += 1;
729                }
730                (
731                    x.value().unwrap_or(0.0),
732                    y.value().unwrap_or(0.0),
733                    repeat.as_repeat(),
734                    PointRole::Circle(CircleShape {
735                        // The radius is a length in the reticle's unit, so it scales with the
736                        // coordinates; the angles are angles and do not.
737                        radius_mil: r.value().map(|r| r * scale),
738                        angles: start.value().zip(end.value()),
739                        // The format needs BOTH angles for a sweep, so "the document declared
740                        // an arc" means both keys are there; it is unreadable when either of
741                        // them is something this module cannot read as a number.
742                        sweep_unreadable: start.is_declared()
743                            && end.is_declared()
744                            && (start.is_unreadable() || end.is_unreadable()),
745                        center_unreadable: x.is_unreadable() || y.is_unreadable(),
746                    }),
747                )
748            }
749            // Decoration this module reads no geometry from, and element types it has never
750            // heard of. Counted, then skipped.
751            Element::Line {} => {
752                *dropped.entry("line").or_insert(0) += 1;
753                continue;
754            }
755            Element::Rect {} => {
756                *dropped.entry("rect").or_insert(0) += 1;
757                continue;
758            }
759            Element::Grid {} => {
760                *dropped.entry("grid").or_insert(0) += 1;
761                continue;
762            }
763            Element::Unknown => {
764                *dropped.entry("unknown").or_insert(0) += 1;
765                continue;
766            }
767        };
768        expand_point(x, y, repeat, scale, base, &mut out)?;
769    }
770    Ok(out)
771}
772
773/// Expand a single point element (`dot`/`tick`/`text`) — with or without a `repeat` — into
774/// `out`, applying `scale` and enforcing the cap.
775fn expand_point(
776    x: f64,
777    y: f64,
778    repeat: Option<&Repeat>,
779    scale: f64,
780    base: PointRole<'_>,
781    out: &mut Vec<ExpandedInstance>,
782) -> Result<(), ReticleError> {
783    // Ventum uses +x = right, +y = down — the same axes the engine's marks use — so a point
784    // at (x, y) maps to (down = y, right = x), unit-scaled.
785    let Some(repeat) = repeat else {
786        // No repeat: a single instance at (x, y).
787        let role = base.role_with_label(None);
788        return push_capped(y * scale, x * scale, role, out);
789    };
790
791    for i in 0..repeat.n {
792        let offset = f64::from(i) * repeat.step;
793        // Step the chosen axis; the other axis is unchanged.
794        let (cx, cy) = match repeat.axis {
795            Axis::X => (x + offset, y),
796            Axis::Y => (x, y + offset),
797        };
798        // The coordinate on the stepped axis decides whether a mirrored copy would land on
799        // center (and so must be skipped, to avoid a duplicate).
800        let stepped = match repeat.axis {
801            Axis::X => cx,
802            Axis::Y => cy,
803        };
804
805        let down = cy * scale;
806        let right = cx * scale;
807        let role = base.role_with_label(base.ladder_label(repeat, i));
808
809        // MBA-1441: a mirrored twin is skipped only when it would be a DUPLICATE, and being
810        // on the mirror line is not by itself enough to make it one. It is for a mark, whose
811        // whole identity is its position — but an arc is also a set of angles, and reflection
812        // reworks those, so a centered ASYMMETRIC arc's twin is a genuinely different shape
813        // (`start: 290, end: 70` reflects to an apex on the other side of the same center).
814        // Counting one where the document drew two is the same undercount this ticket is
815        // named for. The old test is exactly the position half of this one, so nothing about
816        // dot/tick/text expansion changes.
817        let mirror_is_distinct = stepped != 0.0
818            || match &role {
819                Role::Circle(circle) => !circle.is_reflection_of_itself(repeat.axis),
820                Role::Dot | Role::Tick | Role::Text(_) => false,
821            };
822
823        if repeat.mirror && mirror_is_distinct {
824            // Emit the axis-negated twin as well, but never a duplicate at the center. Only
825            // the stepped axis is negated. A mirrored labeled-text copy keeps the SAME label
826            // as its positive twin (magnitude convention: the "5" on the right and the "5"
827            // on the left both read 5). The written schema does not specify this, so it began
828            // as a deliberate assumption; the format's author confirmed it on 2026-08-01
829            // ("mirrored numbers are without signs"). Do not "fix" this into signed labels.
830            let (mirror_down, mirror_right) = match repeat.axis {
831                Axis::X => (down, -right),
832                Axis::Y => (-down, right),
833            };
834            // MBA-1441: an arc's ANGLES have to be reflected along with its center, or a
835            // mirrored horseshoe reports an apex on the wrong side of itself. Every other
836            // role mirrors to itself, so this is a no-op for the marks.
837            let mirror_role = role.mirrored(repeat.axis);
838            push_capped(down, right, role, out)?;
839            push_capped(mirror_down, mirror_right, mirror_role, out)?;
840        } else {
841            push_capped(down, right, role, out)?;
842        }
843    }
844    Ok(())
845}
846
847/// Push one instance, rejecting the reticle the instant the running count would exceed
848/// [`MAX_RETICLE_MARKS`]. Because this checks before every push, expansion allocates at
849/// most `MAX_RETICLE_MARKS + 1` instances regardless of how large a `repeat.n` is, so a
850/// runaway ladder returns promptly instead of hanging or exhausting memory. Text instances
851/// count toward the same cap as marks, so no element kind can grow unbounded.
852fn push_capped(
853    down: f64,
854    right: f64,
855    role: Role,
856    out: &mut Vec<ExpandedInstance>,
857) -> Result<(), ReticleError> {
858    if out.len() >= MAX_RETICLE_MARKS {
859        return Err(ReticleError::TooManyMarks {
860            count: out.len() + 1,
861            max: MAX_RETICLE_MARKS,
862        });
863    }
864    out.push(ExpandedInstance { down, right, role });
865    Ok(())
866}
867
868/// Format a ladder label value so integers print without a fractional part (`5.0 -> "5"`)
869/// while fractional values keep their shortest decimal form (`5.5 -> "5.5"`).
870fn format_label_number(value: f64) -> String {
871    if value.is_finite() && value == value.round() {
872        (value as i64).to_string()
873    } else {
874        value.to_string()
875    }
876}
877
878// ---------------------------------------------------------------------------------------
879// Ventum input model (serde). Deliberately permissive: no `deny_unknown_fields`, so
880// cosmetic keys (color, width, size, len, orient, max_extent, tube_diameter, notes,
881// manufacturer, ...) are ignored rather than rejected. A `circle` goes further, because it
882// is the one decoration this module reads geometry from and reading it must not cost a
883// document its dots: none of the geometry keys a `circle` carries — `x`/`cx`, `y`/`cy`, `r`,
884// `start`, `end`, `repeat` — refuses the document over how it is written, not for its type
885// (`"r": "2mil"`), not for which of the schema's two spellings of a center it uses (`x`
886// beside `cx`), and not for being written twice. The `type` tag is not one of them: an
887// element that names its own type twice is refused by serde's tag reader before any variant,
888// and before this module sees it. See `CircleFields`.
889//
890// Among the drawing ELEMENTS, strict covers what a hold is built from -- a `dot`'s or
891// `tick`'s `x`/`y`, a `text`'s `x`/`y` and its string, and the `repeat` that stamps copies of
892// any of those -- and, before any of that, the `type` tag itself: an element that does not
893// say what it is cannot be read leniently, because leniency is per-variant and there is no
894// variant yet. There is no sane fallback for a mark whose position cannot be read, and a
895// mark's `repeat` quietly degrading to a single copy would drop hold points without saying
896// so. That
897// boundary is stated as code and swept in both directions by
898// `tests::strictness_is_exactly_what_a_hold_is_built_from`, which drives every element type
899// this model can produce against every key this importer reads ON AN ELEMENT (plus two it
900// ignores), and pins the `type` tag separately, since a key sweep has to choose a variant
901// before it can write a key. One of those element keys that starts refusing a document, or
902// stops, turns it red rather than leaving this paragraph wrong. The reticle-level fields
903// below are NOT in that sweep; their nulls are pinned by
904// `tests::null_is_read_as_absence_not_as_a_bad_value` and their wrong types by nothing.
905//
906// Reticle-level fields (`name`, `plane`, `unit`, `ref_magnification`, and `spec` itself) are
907// strict as well, `name` included even though no hold depends on it. That is unchanged since 0.32.0 and is
908// not what this leniency is about: nothing there is per-element, so a bad value is the
909// document saying something wrong about itself rather than one decoration spoiling the rest.
910//
911// What a `circle` can still cost a document:
912//
913//   * The mark cap. `circle` instances now go through repeat expansion, which enforces
914//     MAX_RETICLE_MARKS over everything it materializes, so a document at the cap is refused
915//     where 0.32.0 dropped its rings before expansion began. This is the one way this branch
916//     found to refuse a document 0.32.0 imported; it is a cap decision rather than a parsing
917//     one, is pinned by `tests::a_circle_counts_against_the_mark_cap_unlike_0_32_0`, and is
918//     still owed a fix.
919//   * A refusal that lands before this model is consulted at all: serde_json's PARSER on a
920//     numeric literal outside `f64`'s range (`"r": 1e400`) or JSON nested past the recursion
921//     limit, and serde's tag reader on an element that writes `type` twice. Nothing here can
922//     see any of them, and neither could 0.32.0. They are not about the circle —
923//     `tests::a_refusal_that_is_not_about_a_key_falls_on_every_element_type_alike` shows them
924//     falling identically on element types that read no fields whatsoever.
925//
926// That list is what a corpus of hostile documents turned up, not a proof that nothing else
927// exists — the sweeps named above are where one that came from a key would show itself.
928//
929// Leniency is never silence. A circle that loses geometry this way is still counted in the
930// report, and an ARC that loses any of it lands in `arcs_unresolved` rather than being
931// resolved from a fallback; a circle that loses its `repeat` is counted in
932// `circle_repeats_unreadable`, because the tally for that element is then low.
933// ---------------------------------------------------------------------------------------
934
935/// A whole Ventum reticle: metadata plus its drawing `spec`.
936#[derive(Debug, Deserialize)]
937struct VentumReticle {
938    #[serde(default)]
939    name: String,
940    /// `"ffp"` / `"sfp"` — deserialized directly into the engine's [`FocalPlane`].
941    #[serde(default)]
942    plane: FocalPlane,
943    /// The magnification the subtensions are true at (SFP only).
944    #[serde(default = "default_ref_magnification")]
945    ref_magnification: f64,
946    /// The angular unit for every coordinate in `spec`.
947    #[serde(default)]
948    unit: Unit,
949    #[serde(default)]
950    spec: Spec,
951}
952
953fn default_ref_magnification() -> f64 {
954    1.0
955}
956
957/// The angular unit of a Ventum reticle's coordinates.
958#[derive(Debug, Clone, Copy, Default, Deserialize)]
959#[serde(rename_all = "snake_case")]
960enum Unit {
961    #[default]
962    Mil,
963    Moa,
964}
965
966/// The `spec` field: a JSON array of [`Element`]s, given EITHER as a JSON array OR as a
967/// string containing that array (the schema shows both forms). The custom deserializer
968/// accepts either and parses the string form with `serde_json`.
969#[derive(Debug, Default)]
970struct Spec(Vec<Element>);
971
972impl<'de> Deserialize<'de> for Spec {
973    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
974    where
975        D: Deserializer<'de>,
976    {
977        struct SpecVisitor;
978
979        impl<'de> Visitor<'de> for SpecVisitor {
980            type Value = Spec;
981
982            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
983                f.write_str("a JSON array of reticle elements, or a string containing that array")
984            }
985
986            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
987            where
988                E: de::Error,
989            {
990                let elements: Vec<Element> = serde_json::from_str(v).map_err(de::Error::custom)?;
991                Ok(Spec(elements))
992            }
993
994            fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
995            where
996                A: SeqAccess<'de>,
997            {
998                let elements =
999                    Vec::<Element>::deserialize(de::value::SeqAccessDeserializer::new(seq))?;
1000                Ok(Spec(elements))
1001            }
1002        }
1003
1004        deserializer.deserialize_any(SpecVisitor)
1005    }
1006}
1007
1008/// A number this module treats as cosmetic geometry, exactly as the document wrote it.
1009///
1010/// The three cases are kept apart because leniency degrades a field to its DEFAULT, and a
1011/// default is only an honest answer when the document did not state one. A `circle` with no
1012/// `x` really is centered; a `circle` whose `x` is `"left"` is centered only because this
1013/// module gave up reading it, and an arc resolved about that center would hand the caller an
1014/// apex nobody wrote. So [`Self::Unreadable`] is remembered rather than folded into
1015/// [`Self::Absent`], and the report says a shape was lost instead of inventing one.
1016#[derive(Debug, Clone, Copy, PartialEq, Default)]
1017enum Cosmetic {
1018    /// The document did not write this key — or wrote `null`, which is how JSON spells the
1019    /// absence of a value.
1020    ///
1021    /// A mark's optional `repeat` reads a `null` the same way, through serde's `Option`. It
1022    /// is NOT how the reticle-level fields behave:
1023    /// `#[serde(default)]` fills in a key the document omitted and still refuses a present
1024    /// `null`, which is 0.32.0's behaviour and is deliberately left alone. Both halves are
1025    /// pinned by `tests::null_is_read_as_absence_not_as_a_bad_value`.
1026    #[default]
1027    Absent,
1028    /// The key is there, holding something this module cannot read as a number.
1029    Unreadable,
1030    /// A usable number. Finite by construction — see [`Cosmetic::from_json`].
1031    Value(f64),
1032}
1033
1034impl Cosmetic {
1035    /// The number, when there is a usable one.
1036    fn value(self) -> Option<f64> {
1037        match self {
1038            Cosmetic::Value(number) => Some(number),
1039            Cosmetic::Absent | Cosmetic::Unreadable => None,
1040        }
1041    }
1042
1043    /// Whether the document wrote this key at all, readable or not.
1044    fn is_declared(self) -> bool {
1045        !matches!(self, Cosmetic::Absent)
1046    }
1047
1048    /// Whether the document wrote this key and this module could not read it.
1049    fn is_unreadable(self) -> bool {
1050        matches!(self, Cosmetic::Unreadable)
1051    }
1052
1053    /// Classify a cosmetic number the document wrote, never failing the document over one
1054    /// this module cannot use.
1055    ///
1056    /// A typed field — `Option<f64>` included — absorbs an ABSENT or `null` value but still
1057    /// *rejects* a present one of the wrong type, and one rejected field fails the entire
1058    /// document, not the element that carried it. That is the wrong trade for a key this
1059    /// module only ever reads as a nicety: a Ventum tool is free to write `"r": "2mil"`,
1060    /// `"r": {"v": 2, "unit": "mil"}` or anything else its UI finds convenient, and none of
1061    /// that is a reason to refuse a reticle whose dots are perfectly good. So the value
1062    /// arrives as arbitrary JSON and is classified here, never rejected.
1063    ///
1064    /// What the classification can and cannot see: `Value::as_f64` answers `None` for every
1065    /// JSON value that is not a number, which is the case this exists for. The `is_finite`
1066    /// guard behind it cannot fire on JSON at all — `NaN` and `Infinity` are not JSON
1067    /// literals, and a numeric literal outside `f64`'s range (`1e400`) is refused by
1068    /// serde_json's PARSER, which fails the whole document before any [`serde_json::Value`]
1069    /// exists for this function to see. The guard is kept only so a non-JSON deserializer
1070    /// could not slip a non-finite number past; it catches nothing a Ventum file can contain.
1071    fn from_json(value: serde_json::Value) -> Cosmetic {
1072        if value.is_null() {
1073            return Cosmetic::Absent;
1074        }
1075        match value.as_f64().filter(|v| v.is_finite()) {
1076            Some(number) => Cosmetic::Value(number),
1077            None => Cosmetic::Unreadable,
1078        }
1079    }
1080}
1081
1082/// A `circle`'s `repeat`, exactly as the document wrote it. See [`CosmeticRepeat::from_json`].
1083#[derive(Debug, Default)]
1084enum CosmeticRepeat {
1085    /// The document did not write a `repeat` (or wrote `null`).
1086    #[default]
1087    Absent,
1088    /// It wrote one this module cannot read — precisely, one [`Repeat`] itself refuses:
1089    /// missing `axis`, `step` or `n`, any of them of the wrong type or out of range, an
1090    /// `axis` that is neither `x` nor `y`, a `repeat` that is not even an object.
1091    Unreadable,
1092    /// A usable repeat.
1093    Present(Repeat),
1094}
1095
1096impl CosmeticRepeat {
1097    /// The repeat to expand with, `None` when there is none to use.
1098    fn as_repeat(&self) -> Option<&Repeat> {
1099        match self {
1100            CosmeticRepeat::Present(repeat) => Some(repeat),
1101            CosmeticRepeat::Absent | CosmeticRepeat::Unreadable => None,
1102        }
1103    }
1104
1105    /// Whether the document declared a repeat this module could not read.
1106    fn is_unreadable(&self) -> bool {
1107        matches!(self, CosmeticRepeat::Unreadable)
1108    }
1109
1110    /// Classify a `circle`'s `repeat`, degrading an unusable one to "no repeat" instead of
1111    /// failing the document over it.
1112    ///
1113    /// This leniency belongs to the CIRCLE, not to [`Repeat`], and the two must not be
1114    /// confused. On a `dot`, `tick` or `text` a repeat is not decoration: it stamps the hold
1115    /// points the shooter aims with, so a malformed one that quietly collapsed to a single
1116    /// copy would drop marks without saying so — the exact silence MBA-1441 exists to end,
1117    /// and loud is the only safe answer there. On a `circle` it stamps copies of something
1118    /// that is never a mark under any reading, so an unreadable repeat costs the report a
1119    /// count (declared in [`VentumImportReport::circle_repeats_unreadable`]) and costs the
1120    /// reticle nothing. [`Repeat`] itself therefore keeps its strict derive; this is the one
1121    /// place a value is offered to it and a failure turned into a shrug.
1122    ///
1123    /// The degradation is all-or-nothing on purpose. `axis`, `step` and `n` have no
1124    /// defensible defaults — `x` is not more plausible than `y`, and no copy count is more
1125    /// plausible than another — so a repeat with any of them unusable is not a repeat. "No
1126    /// repeat", one instance at `(x, y)`, is what an absent `repeat` gives, and is what
1127    /// 0.32.0 gave every `circle` in every document, since the variant read no fields at all.
1128    fn from_json(value: serde_json::Value) -> CosmeticRepeat {
1129        if value.is_null() {
1130            return CosmeticRepeat::Absent;
1131        }
1132        match serde_json::from_value::<Repeat>(value) {
1133            Ok(repeat) => CosmeticRepeat::Present(repeat),
1134            Err(_) => CosmeticRepeat::Unreadable,
1135        }
1136    }
1137}
1138
1139/// One drawing element, internally tagged on `"type"`. Unknown cosmetic fields on each
1140/// variant are ignored; unknown element *types* map to [`Element::Unknown`].
1141#[derive(Debug, Deserialize)]
1142#[serde(tag = "type", rename_all = "snake_case")]
1143enum Element {
1144    /// A filled dot (hold-bearing). `r` and any other cosmetic fields are ignored.
1145    Dot {
1146        x: f64,
1147        y: f64,
1148        #[serde(default)]
1149        repeat: Option<Repeat>,
1150    },
1151    /// A short hash mark (hold-bearing); its hold point is its `(x, y)` anchor. `len` and
1152    /// `orient` are ignored.
1153    Tick {
1154        x: f64,
1155        y: f64,
1156        #[serde(default)]
1157        repeat: Option<Repeat>,
1158    },
1159    /// A standalone text label positioned in reticle coordinates. `size` is ignored.
1160    Text {
1161        x: f64,
1162        y: f64,
1163        text: String,
1164        #[serde(default)]
1165        repeat: Option<Repeat>,
1166    },
1167    /// Decoration — dropped. Its geometry fields are ignored (empty struct variant so
1168    /// serde skips every field).
1169    Line {},
1170    /// A ring, or — when it carries `start`/`end` — an arc such as a horseshoe. Decoration
1171    /// either way: still dropped, but no longer in silence (MBA-1441). Its keys are read by
1172    /// [`CircleFields`], which refuses the document over none of them.
1173    Circle(CircleFields),
1174    /// Decoration — dropped.
1175    Rect {},
1176    /// Decoration — dropped.
1177    Grid {},
1178    /// Any unknown future element type — dropped.
1179    #[serde(other)]
1180    Unknown,
1181}
1182
1183/// The keys of a `circle`, read so that none of them can refuse the document.
1184///
1185/// A derived `Deserialize` gives a struct three ways to reject an element, and a `circle` is
1186/// decoration this module must be able to skip rather than die on, so all three are answered
1187/// here instead:
1188///
1189/// * **an unknown key** — already harmless, since no variant in this model sets
1190///   `deny_unknown_fields`; the `_` arm below keeps it that way,
1191/// * **a key whose value is the wrong type** — [`Cosmetic::from_json`] and
1192///   [`CosmeticRepeat::from_json`] classify a value rather than typing it, so `"r": "2mil"`
1193///   degrades to "unreadable" instead of failing the parse,
1194/// * **a key written twice** — which is what a derived struct calls `duplicate field \`x\``
1195///   and refuses the whole document over. That is the one this hand-written visitor exists
1196///   for. It bites two ways a Ventum file really can be written: the same key repeated
1197///   (`{"r": 1, "r": 2}`, which JSON permits and `serde_json::Value` itself resolves by
1198///   keeping the last), and the schema's two spellings of one center used together
1199///   (`{"x": 1, "cx": 2}`, where the derive's `alias` collapses both onto `x` and then calls
1200///   the second one a duplicate). 0.32.0 accepted all of those, because its `Circle {}` read
1201///   no keys at all; a reticle whose dots are perfectly good must not start failing over how
1202///   its rings spell a center.
1203///
1204/// The rule is last-one-wins, in document order — the same answer `serde_json::Value` gives a
1205/// repeated key, applied to the alias pairs as well. A mark's fields get none of this: see
1206/// the serde section above for why a `dot`'s `x` stays strict.
1207#[derive(Debug, Default)]
1208struct CircleFields {
1209    /// Center, `+x` right. `cx` is the schema's other spelling of the same field.
1210    x: Cosmetic,
1211    /// Center, `+y` down. `cy` is the other spelling.
1212    y: Cosmetic,
1213    /// Radius, in the reticle's own unit.
1214    r: Cosmetic,
1215    /// Sweep start in degrees from 3 o'clock, clockwise (see the module documentation — 270
1216    /// is the TOP of the reticle). Present only on an arc.
1217    start: Cosmetic,
1218    /// Sweep end, same convention. An arc needs BOTH: one angle alone describes no sweep.
1219    end: Cosmetic,
1220    /// Copies of the ring or arc.
1221    repeat: CosmeticRepeat,
1222}
1223
1224impl<'de> Deserialize<'de> for CircleFields {
1225    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1226    where
1227        D: Deserializer<'de>,
1228    {
1229        struct CircleFieldsVisitor;
1230
1231        impl<'de> Visitor<'de> for CircleFieldsVisitor {
1232            type Value = CircleFields;
1233
1234            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1235                f.write_str("a circle element")
1236            }
1237
1238            fn visit_map<A>(self, mut map: A) -> Result<CircleFields, A::Error>
1239            where
1240                A: de::MapAccess<'de>,
1241            {
1242                let mut fields = CircleFields::default();
1243                while let Some(key) = map.next_key::<String>()? {
1244                    match key.as_str() {
1245                        "x" | "cx" => fields.x = Cosmetic::from_json(map.next_value()?),
1246                        "y" | "cy" => fields.y = Cosmetic::from_json(map.next_value()?),
1247                        "r" => fields.r = Cosmetic::from_json(map.next_value()?),
1248                        "start" => fields.start = Cosmetic::from_json(map.next_value()?),
1249                        "end" => fields.end = Cosmetic::from_json(map.next_value()?),
1250                        "repeat" => fields.repeat = CosmeticRepeat::from_json(map.next_value()?),
1251                        // Every other key — `color`, `width`, `max_extent`, the next thing
1252                        // the authoring tool invents — is decoration on decoration. Skipped
1253                        // without materializing it.
1254                        _ => {
1255                            map.next_value::<de::IgnoredAny>()?;
1256                        }
1257                    }
1258                }
1259                Ok(fields)
1260            }
1261        }
1262
1263        deserializer.deserialize_map(CircleFieldsVisitor)
1264    }
1265}
1266
1267/// The `repeat` operator: stamp `n` copies of an element along one axis, optionally
1268/// mirrored, optionally auto-numbering a text ladder's labels.
1269///
1270/// Strictly typed, and deliberately so: on a `dot`, `tick` or `text` this is what stamps the
1271/// hold points, and a malformed repeat that degraded to one copy would drop marks in silence.
1272/// A `circle`'s repeat stamps nothing holdable, so that one field — and only that one — is
1273/// offered to this type through [`CosmeticRepeat::from_json`], which keeps the failure
1274/// instead of propagating it.
1275#[derive(Debug, Deserialize)]
1276struct Repeat {
1277    /// Which axis to step along.
1278    axis: Axis,
1279    /// Spacing between copies, in the reticle's unit.
1280    step: f64,
1281    /// Number of copies.
1282    n: u32,
1283    /// When true, also emit the axis-negated copy (a `±` symmetric ladder) — except where
1284    /// that copy would be a duplicate of the original, which for a mark means landing on the
1285    /// mirror line and for an arc additionally means the reflection leaving its angles
1286    /// unchanged (see [`CircleShape::is_reflection_of_itself`]).
1287    #[serde(default)]
1288    mirror: bool,
1289    /// (text only) When true, rewrite each copy's label to `label_start + i*label_step`.
1290    #[serde(default)]
1291    label: bool,
1292    /// (text ladder) The value of the first copy's auto-number.
1293    #[serde(rename = "labelStart", default)]
1294    label_start: f64,
1295    /// (text ladder) The auto-number increment per copy.
1296    #[serde(rename = "labelStep", default = "default_label_step")]
1297    label_step: f64,
1298}
1299
1300fn default_label_step() -> f64 {
1301    1.0
1302}
1303
1304/// The axis a [`Repeat`] steps along.
1305#[derive(Debug, Clone, Copy, Deserialize)]
1306#[serde(rename_all = "snake_case")]
1307enum Axis {
1308    X,
1309    Y,
1310}
1311
1312#[cfg(test)]
1313mod tests {
1314    use super::*;
1315    use crate::reticle::hold_point_in_reticle;
1316
1317    /// Bero's exact MBR dot-tree spec (the array form), as three `dot` rows each stamped by
1318    /// a horizontal mirrored `repeat`.
1319    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}}]"#;
1320
1321    /// Collect the `right_mil` values of every mark on a given `down_mil` row, sorted.
1322    fn row_rights(desc: &ReticleDescription, down: f64) -> Vec<f64> {
1323        let mut rights: Vec<f64> = desc
1324            .marks
1325            .iter()
1326            .filter(|m| m.down_mil == down)
1327            .map(|m| m.right_mil)
1328            .collect();
1329        rights.sort_by(|a, b| a.partial_cmp(b).unwrap());
1330        rights
1331    }
1332
1333    /// The set `{±1, ±2, ..., ±max}`, sorted ascending — the expected spread of one MBR row.
1334    fn symmetric_spread(max: i32) -> Vec<f64> {
1335        let mut v: Vec<f64> = Vec::new();
1336        for k in 1..=max {
1337            v.push(-f64::from(k));
1338        }
1339        for k in 1..=max {
1340            v.push(f64::from(k));
1341        }
1342        v.sort_by(|a, b| a.partial_cmp(b).unwrap());
1343        v
1344    }
1345
1346    fn assert_mbr(desc: &ReticleDescription) {
1347        // 78 marks: row y=4 spans ±1..±9 (18), y=8 spans ±1..±13 (26), y=12 spans ±1..±17 (34).
1348        assert_eq!(desc.marks.len(), 78, "MBR expands to 78 holdable marks");
1349        assert_eq!(
1350            row_rights(desc, 4.0),
1351            symmetric_spread(9),
1352            "row down=4 -> ±1..±9"
1353        );
1354        assert_eq!(
1355            row_rights(desc, 8.0),
1356            symmetric_spread(13),
1357            "row down=8 -> ±1..±13"
1358        );
1359        assert_eq!(
1360            row_rights(desc, 12.0),
1361            symmetric_spread(17),
1362            "row down=12 -> ±1..±17"
1363        );
1364        assert_eq!(row_rights(desc, 4.0).len(), 18);
1365        assert_eq!(row_rights(desc, 8.0).len(), 26);
1366        assert_eq!(row_rights(desc, 12.0).len(), 34);
1367        // Every mark is a dot, none sits on the vertical axis, and none exceeds its row span.
1368        assert!(
1369            desc.marks.iter().all(|m| m.kind == MarkKind::Dot),
1370            "all MBR marks are dots"
1371        );
1372        assert!(
1373            desc.marks.iter().all(|m| m.right_mil != 0.0),
1374            "no MBR mark lands on the center line"
1375        );
1376        assert!(
1377            row_rights(desc, 4.0).iter().all(|&r| r.abs() <= 9.0),
1378            "row down=4 has nothing beyond ±9"
1379        );
1380    }
1381
1382    #[test]
1383    fn mbr_array_form_expands_to_the_full_dot_tree() {
1384        let json = format!(r#"{{"name":"MBR","plane":"ffp","unit":"mil","spec":{MBR_SPEC}}}"#);
1385        let desc = import_ventum_reticle(&json).unwrap();
1386        assert_eq!(desc.name, "MBR");
1387        assert_eq!(desc.focal_plane, FocalPlane::First);
1388        assert_mbr(&desc);
1389    }
1390
1391    #[test]
1392    fn mbr_string_form_parses_identically_to_the_array_form() {
1393        // `spec` given as a STRING containing the JSON array.
1394        let string_form = serde_json::json!({
1395            "name": "MBR", "plane": "ffp", "unit": "mil", "spec": MBR_SPEC
1396        })
1397        .to_string();
1398        let array_form =
1399            format!(r#"{{"name":"MBR","plane":"ffp","unit":"mil","spec":{MBR_SPEC}}}"#);
1400
1401        let from_string = import_ventum_reticle(&string_form).unwrap();
1402        let from_array = import_ventum_reticle(&array_form).unwrap();
1403
1404        assert_mbr(&from_string);
1405        assert_eq!(
1406            from_string, from_array,
1407            "the string-wrapped spec must import identically to the array spec"
1408        );
1409    }
1410
1411    #[test]
1412    fn sfp_carries_reference_magnification_but_ffp_normalizes_it() {
1413        let sfp = import_ventum_reticle(
1414            r#"{"name":"SE","plane":"sfp","ref_magnification":6,"unit":"mil",
1415                "spec":[{"type":"dot","x":0,"y":1}]}"#,
1416        )
1417        .unwrap();
1418        assert_eq!(sfp.focal_plane, FocalPlane::Second);
1419        assert_eq!(sfp.reference_magnification, 6.0);
1420
1421        // An FFP reticle ignores any ref_magnification and normalizes to 1.0.
1422        let ffp = import_ventum_reticle(
1423            r#"{"name":"F","plane":"ffp","ref_magnification":6,"unit":"mil",
1424                "spec":[{"type":"dot","x":0,"y":1}]}"#,
1425        )
1426        .unwrap();
1427        assert_eq!(ffp.focal_plane, FocalPlane::First);
1428        assert_eq!(ffp.reference_magnification, 1.0);
1429    }
1430
1431    #[test]
1432    fn moa_coordinates_convert_to_milliradians() {
1433        let desc = import_ventum_reticle(
1434            r#"{"name":"M","unit":"moa","spec":[{"type":"dot","x":0,"y":2}]}"#,
1435        )
1436        .unwrap();
1437        assert_eq!(desc.marks.len(), 1);
1438        assert!(
1439            (desc.marks[0].down_mil - 2.0 * MOA_TO_MIL).abs() < 1e-6,
1440            "2 MOA -> {} mil (~0.581776)",
1441            desc.marks[0].down_mil
1442        );
1443    }
1444
1445    #[test]
1446    fn text_binds_to_the_nearest_mark_and_far_text_is_dropped() {
1447        let desc = import_ventum_reticle(
1448            r#"{"name":"T","unit":"mil","spec":[
1449                {"type":"tick","x":0,"y":5},
1450                {"type":"text","x":0.5,"y":5,"text":"5"},
1451                {"type":"text","x":10,"y":10,"text":"far"}
1452            ]}"#,
1453        )
1454        .unwrap();
1455        // One tick -> one mark; the nearby "5" binds, the far "far" is dropped.
1456        assert_eq!(desc.marks.len(), 1);
1457        assert_eq!(desc.marks[0].down_mil, 5.0);
1458        assert_eq!(desc.marks[0].kind, MarkKind::Hash);
1459        assert_eq!(desc.marks[0].label.as_deref(), Some("5"));
1460        assert!(
1461            desc.marks.iter().all(|m| m.label.as_deref() != Some("far")),
1462            "distant text must not bind"
1463        );
1464    }
1465
1466    #[test]
1467    fn mirror_never_duplicates_a_center_mark() {
1468        // A mirrored ladder whose stepped coordinate is 0 emits a single mark (no twin).
1469        let center = import_ventum_reticle(
1470            r#"{"name":"C","unit":"mil","spec":[
1471                {"type":"tick","x":0,"y":1,"repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
1472            ]}"#,
1473        )
1474        .unwrap();
1475        assert_eq!(center.marks.len(), 1, "no duplicate at the center");
1476        assert_eq!(center.marks[0].right_mil, 0.0);
1477
1478        // Off center, the mirror emits both ± copies.
1479        let off = import_ventum_reticle(
1480            r#"{"name":"O","unit":"mil","spec":[
1481                {"type":"tick","x":1,"y":1,"repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
1482            ]}"#,
1483        )
1484        .unwrap();
1485        assert_eq!(off.marks.len(), 2, "mirror of an off-center mark yields ±1");
1486        let mut rights: Vec<f64> = off.marks.iter().map(|m| m.right_mil).collect();
1487        rights.sort_by(|a, b| a.partial_cmp(b).unwrap());
1488        assert_eq!(rights, vec![-1.0, 1.0]);
1489    }
1490
1491    #[test]
1492    fn text_ladder_auto_numbers_each_copy() {
1493        // Factored expander: a labeled text ladder renumbers each copy from labelStart by
1494        // labelStep (defaults 0 and 1).
1495        let elements = vec![Element::Text {
1496            x: 0.0,
1497            y: 1.0,
1498            text: "base".to_string(),
1499            repeat: Some(Repeat {
1500                axis: Axis::Y,
1501                step: 1.0,
1502                n: 3,
1503                mirror: false,
1504                label: true,
1505                label_start: 0.0,
1506                label_step: 1.0,
1507            }),
1508        }];
1509        let mut dropped = BTreeMap::new();
1510        let mut circle_repeats_unreadable = 0usize;
1511        let instances =
1512            expand_elements(&elements, 1.0, &mut dropped, &mut circle_repeats_unreadable).unwrap();
1513        assert!(dropped.is_empty(), "a text ladder drops nothing");
1514        assert_eq!(circle_repeats_unreadable, 0);
1515        assert_eq!(
1516            instances,
1517            vec![
1518                ExpandedInstance {
1519                    down: 1.0,
1520                    right: 0.0,
1521                    role: Role::Text("0".to_string())
1522                },
1523                ExpandedInstance {
1524                    down: 2.0,
1525                    right: 0.0,
1526                    role: Role::Text("1".to_string())
1527                },
1528                ExpandedInstance {
1529                    down: 3.0,
1530                    right: 0.0,
1531                    role: Role::Text("2".to_string())
1532                },
1533            ]
1534        );
1535    }
1536
1537    #[test]
1538    fn label_numbers_print_integers_without_a_decimal() {
1539        assert_eq!(format_label_number(5.0), "5");
1540        assert_eq!(format_label_number(0.0), "0");
1541        assert_eq!(format_label_number(-3.0), "-3");
1542        assert_eq!(format_label_number(5.5), "5.5");
1543    }
1544
1545    #[test]
1546    fn a_runaway_repeat_is_capped_promptly() {
1547        let json = r#"{"name":"X","unit":"mil","spec":[
1548            {"type":"dot","x":0,"y":1,"repeat":{"axis":"x","step":1,"n":100000}}
1549        ]}"#;
1550        // Must reject with TooManyMarks without materializing 100000 marks (the cap is
1551        // enforced during expansion, so this returns immediately).
1552        assert!(matches!(
1553            import_ventum_reticle(json),
1554            Err(ReticleError::TooManyMarks { .. })
1555        ));
1556    }
1557
1558    #[test]
1559    fn malformed_json_reports_invalid_spec() {
1560        assert!(matches!(
1561            import_ventum_reticle("{not json"),
1562            Err(ReticleError::InvalidSpec(_))
1563        ));
1564        // A `spec` string that is not itself a JSON array is also an invalid spec.
1565        assert!(matches!(
1566            import_ventum_reticle(r#"{"name":"X","spec":"not-an-array"}"#),
1567            Err(ReticleError::InvalidSpec(_))
1568        ));
1569    }
1570
1571    #[test]
1572    fn decoration_and_unknown_elements_are_dropped() {
1573        let desc = import_ventum_reticle(
1574            r#"{"name":"D","unit":"mil","spec":[
1575                {"type":"line","x1":-5,"y1":0,"x2":5,"y2":0,"width":0.05},
1576                {"type":"circle","x":0,"y":0,"r":1.0},
1577                {"type":"rect","x":-1,"y":-1,"w":2,"h":2},
1578                {"type":"grid","x0":-5,"y0":-5,"x1":5,"y1":5,"step":1},
1579                {"type":"future_shape","x":0,"y":0},
1580                {"type":"dot","x":0,"y":2,"color":"red","width":0.1}
1581            ]}"#,
1582        )
1583        .unwrap();
1584        // Only the dot survives; every decoration and the unknown type are dropped.
1585        assert_eq!(desc.marks.len(), 1);
1586        assert_eq!(desc.marks[0].down_mil, 2.0);
1587        assert_eq!(desc.marks[0].kind, MarkKind::Dot);
1588    }
1589
1590    // ----------------------------------------------------------------------------------
1591    // MBA-1441: dropped elements are reported, and an arc is resolved rather than guessed.
1592    // ----------------------------------------------------------------------------------
1593
1594    /// The author's reference horseshoe: `start: 200, end: 340` opens DOWNWARD, so its apex
1595    /// is at 270 degrees, which is the TOP of the reticle — the one consequence of the
1596    /// clockwise/`+y`-down convention that is opposite to a compass. This test is the
1597    /// convention's regression guard: flip the sine's sign and the apex lands at the bottom.
1598    #[test]
1599    fn a_horseshoe_apex_resolves_to_the_top_of_the_reticle() {
1600        let (_, report) = import_ventum_reticle_with_report(
1601            r#"{"name":"H","unit":"mil","spec":[
1602                {"type":"dot","x":0,"y":1},
1603                {"type":"circle","x":0,"y":0,"r":2,"start":200,"end":340}
1604            ]}"#,
1605        )
1606        .unwrap();
1607
1608        assert_eq!(report.arcs.len(), 1, "the horseshoe is reported");
1609        let arc = report.arcs[0];
1610        assert!(
1611            (arc.sweep_degrees - 140.0).abs() < 1e-9,
1612            "200 -> 340 clockwise is 140 deg"
1613        );
1614
1615        // Apex at 270 deg, radius 2: (cos 270, sin 270) = (0, -1) with +y DOWN, so 2 mil UP.
1616        assert!(
1617            arc.apex.right_mil.abs() < 1e-9,
1618            "apex is on the vertical axis"
1619        );
1620        assert!(
1621            (arc.apex.down_mil + 2.0).abs() < 1e-9,
1622            "apex must be 2 mil ABOVE center (down_mil -2), not below: {:?}",
1623            arc.apex
1624        );
1625        // Both tips sit below the apex and straddle it, leaving the gap at the bottom.
1626        assert!(
1627            arc.start_tip.right_mil < 0.0,
1628            "the 200 deg tip is on the left"
1629        );
1630        assert!(
1631            arc.end_tip.right_mil > 0.0,
1632            "the 340 deg tip is on the right"
1633        );
1634        for tip in [arc.start_tip, arc.end_tip] {
1635            assert!(
1636                tip.down_mil > arc.apex.down_mil,
1637                "a tip of a downward-opening horseshoe is below its apex: {tip:?}"
1638            );
1639        }
1640    }
1641
1642    /// An arc is reported, not imported: the description is exactly what it was before this
1643    /// existed. Inventing three marks per horseshoe would silently move the nearest mark for
1644    /// every Ventum reticle imported since 0.32.0.
1645    #[test]
1646    fn an_arc_is_reported_but_never_becomes_a_mark() {
1647        let spec = |arc: &str| {
1648            format!(r#"{{"name":"A","unit":"mil","spec":[{{"type":"tick","x":0,"y":5}}{arc}]}}"#)
1649        };
1650        let without = import_ventum_reticle(&spec("")).unwrap();
1651        let with = import_ventum_reticle(&spec(
1652            r#",{"type":"circle","x":0,"y":0,"r":2,"start":200,"end":340}"#,
1653        ))
1654        .unwrap();
1655        assert_eq!(
1656            without, with,
1657            "adding an arc must not change a single imported mark"
1658        );
1659    }
1660
1661    /// A full ring is decoration under any reading and gets no arc entry — but it is still
1662    /// counted, under its own tag, so "circle x1" and "arc x1" mean different things.
1663    #[test]
1664    fn a_ring_is_counted_as_a_circle_and_an_arc_as_an_arc() {
1665        let (_, report) = import_ventum_reticle_with_report(
1666            r#"{"name":"R","unit":"mil","spec":[
1667                {"type":"dot","x":0,"y":1},
1668                {"type":"circle","x":0,"y":0,"r":2},
1669                {"type":"circle","x":0,"y":0,"r":3,"start":0,"end":360},
1670                {"type":"circle","x":0,"y":0,"r":4,"start":200,"end":340}
1671            ]}"#,
1672        )
1673        .unwrap();
1674        assert_eq!(
1675            report.dropped_element_types,
1676            vec![("arc".to_string(), 1), ("circle".to_string(), 2)],
1677            "a whole-revolution sweep is a ring, not an arc"
1678        );
1679        assert_eq!(report.arcs.len(), 1);
1680        assert_eq!(report.dropped_elements, 3);
1681    }
1682
1683    /// The tally names every type the document drew that produced no hold, and unbound text
1684    /// is in it: a label that bound to nothing is a lost label, not a lost shape.
1685    #[test]
1686    fn the_report_tallies_every_dropped_type_including_unbound_text() {
1687        let (desc, report) = import_ventum_reticle_with_report(
1688            r#"{"name":"D","unit":"mil","spec":[
1689                {"type":"dot","x":0,"y":2},
1690                {"type":"text","x":0.2,"y":2,"text":"near"},
1691                {"type":"text","x":9,"y":9,"text":"far"},
1692                {"type":"line","x1":-5,"y1":0,"x2":5,"y2":0},
1693                {"type":"line","x1":0,"y1":-5,"x2":0,"y2":5},
1694                {"type":"rect","x":-1,"y":-1,"w":2,"h":2},
1695                {"type":"grid","x0":-5,"y0":-5,"x1":5,"y1":5,"step":1},
1696                {"type":"future_shape","x":0,"y":0}
1697            ]}"#,
1698        )
1699        .unwrap();
1700        assert_eq!(desc.marks.len(), 1);
1701        assert_eq!(desc.marks[0].label.as_deref(), Some("near"));
1702        assert_eq!(
1703            report.dropped_element_types,
1704            vec![
1705                ("grid".to_string(), 1),
1706                ("line".to_string(), 2),
1707                ("rect".to_string(), 1),
1708                ("text".to_string(), 1),
1709                ("unknown".to_string(), 1),
1710            ]
1711        );
1712        assert_eq!(report.dropped_elements, 6);
1713        assert_eq!(
1714            report.tally(),
1715            "grid x1, line x2, rect x1, text x1, unknown x1"
1716        );
1717        assert!(!report.is_empty());
1718    }
1719
1720    /// A document that imports whole says so: an empty report is the signal that nothing was
1721    /// lost, and it is what the MBR dot tree produces.
1722    #[test]
1723    fn a_fully_representable_reticle_reports_nothing_dropped() {
1724        let json = format!(r#"{{"name":"MBR","plane":"ffp","unit":"mil","spec":{MBR_SPEC}}}"#);
1725        let (_, report) = import_ventum_reticle_with_report(&json).unwrap();
1726        assert!(report.is_empty(), "MBR is all dots: {report:?}");
1727        assert_eq!(report, VentumImportReport::default());
1728        assert_eq!(report.tally(), "");
1729    }
1730
1731    /// A mirrored pair of horseshoes is two arcs, and the twin's ANGLES are reflected along
1732    /// with its center. Without that, a mirrored horseshoe would report an apex on the wrong
1733    /// side of its own arc.
1734    #[test]
1735    fn a_mirrored_arc_reflects_its_angles_not_just_its_center() {
1736        // A horseshoe centered 3 right, opening toward the center line (apex at 0 deg, i.e.
1737        // to the RIGHT of its own center), mirrored across the vertical axis.
1738        let (_, report) = import_ventum_reticle_with_report(
1739            r#"{"name":"M","unit":"mil","spec":[
1740                {"type":"circle","x":3,"y":0,"r":1,"start":290,"end":70,
1741                 "repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
1742            ]}"#,
1743        )
1744        .unwrap();
1745        assert_eq!(report.arcs.len(), 2, "mirror emits both horseshoes");
1746        let right = report
1747            .arcs
1748            .iter()
1749            .find(|a| a.center.right_mil > 0.0)
1750            .unwrap();
1751        let left = report
1752            .arcs
1753            .iter()
1754            .find(|a| a.center.right_mil < 0.0)
1755            .unwrap();
1756
1757        // The original's apex is at 0 deg: 1 mil right of its own center, so 4 mil right.
1758        assert!(
1759            (right.apex.right_mil - 4.0).abs() < 1e-9,
1760            "{:?}",
1761            right.apex
1762        );
1763        // The twin must be the mirror image: 1 mil LEFT of its center at -3, so -4 — not -2,
1764        // which is what an unreflected sweep would give.
1765        assert!(
1766            (left.apex.right_mil + 4.0).abs() < 1e-9,
1767            "mirrored apex must reflect too (got {:?}, an unreflected sweep gives -2)",
1768            left.apex
1769        );
1770        // Reflection preserves the sweep's extent and keeps both apexes on the same row.
1771        assert!((left.sweep_degrees - right.sweep_degrees).abs() < 1e-9);
1772        assert!((left.apex.down_mil - right.apex.down_mil).abs() < 1e-9);
1773    }
1774
1775    /// An arc the module cannot resolve (no radius) is still counted, and the discrepancy
1776    /// between the tally and `arcs.len()` is stated rather than left to be noticed.
1777    #[test]
1778    fn an_arc_without_a_radius_is_counted_as_unresolved() {
1779        let (_, report) = import_ventum_reticle_with_report(
1780            r#"{"name":"U","unit":"mil","spec":[
1781                {"type":"circle","x":0,"y":0,"start":200,"end":340},
1782                {"type":"circle","x":0,"y":0,"r":2,"start":200,"end":340}
1783            ]}"#,
1784        )
1785        .unwrap();
1786        assert_eq!(report.dropped_element_types, vec![("arc".to_string(), 2)]);
1787        assert_eq!(report.arcs.len(), 1);
1788        assert_eq!(report.arcs_unresolved, 1);
1789        assert_eq!(
1790            report.arcs.len() + report.arcs_unresolved,
1791            report.dropped_elements,
1792            "every counted arc is either resolved or explicitly unresolved"
1793        );
1794    }
1795
1796    /// An MOA arc's center and radius scale to milliradians; its angles do not. Scaling the
1797    /// angles would rotate the horseshoe by a factor of 3.44.
1798    #[test]
1799    fn moa_scales_an_arcs_lengths_but_never_its_angles() {
1800        let (_, report) = import_ventum_reticle_with_report(
1801            r#"{"name":"M","unit":"moa","spec":[
1802                {"type":"circle","x":0,"y":0,"r":2,"start":200,"end":340}
1803            ]}"#,
1804        )
1805        .unwrap();
1806        let arc = report.arcs[0];
1807        assert!((arc.radius_mil - 2.0 * MOA_TO_MIL).abs() < 1e-9);
1808        assert_eq!(arc.start_degrees, 200.0);
1809        assert_eq!(arc.end_degrees, 340.0);
1810        assert!((arc.sweep_degrees - 140.0).abs() < 1e-9);
1811        // Apex still at 270 deg (straight up), now 2 MOA above center.
1812        assert!((arc.apex.down_mil + 2.0 * MOA_TO_MIL).abs() < 1e-9);
1813    }
1814
1815    /// `cx`/`cy` is the schema's other spelling of a circle's center, and a circle naming
1816    /// neither is centered on the reticle — both must still parse, because they always did.
1817    #[test]
1818    fn a_circle_accepts_cx_cy_and_defaults_to_the_center() {
1819        let (_, report) = import_ventum_reticle_with_report(
1820            r#"{"name":"C","unit":"mil","spec":[
1821                {"type":"circle","cx":1,"cy":2,"r":1,"start":0,"end":180},
1822                {"type":"circle","r":1,"start":0,"end":180}
1823            ]}"#,
1824        )
1825        .unwrap();
1826        assert_eq!(report.arcs.len(), 2);
1827        assert_eq!(report.arcs[0].center.right_mil, 1.0);
1828        assert_eq!(report.arcs[0].center.down_mil, 2.0);
1829        assert_eq!(report.arcs[1].center, VentumArcPoint::default());
1830    }
1831
1832    /// A cosmetic key must never fail the import, whatever type the authoring tool wrote it
1833    /// as. `r` carrying a unit suffix (`"2mil"`) or a richer object is the realistic case, and
1834    /// giving `circle` typed fields made it reject the WHOLE document — the dots that have
1835    /// imported since 0.32.0 would have gone with the ring.
1836    #[test]
1837    fn a_non_numeric_radius_is_ignored_rather_than_rejected() {
1838        for radius in [
1839            r#""2mil""#,
1840            r#"{"v":2,"unit":"mil"}"#,
1841            "[2]",
1842            "true",
1843            "null",
1844        ] {
1845            let json = format!(
1846                r#"{{"name":"L","unit":"mil","spec":[
1847                    {{"type":"dot","x":0,"y":1}},
1848                    {{"type":"circle","x":0,"y":0,"r":{radius}}}
1849                ]}}"#
1850            );
1851            let (desc, report) = import_ventum_reticle_with_report(&json)
1852                .unwrap_or_else(|e| panic!("r: {radius} must not fail the import, got {e:?}"));
1853
1854            // The document imported: the sibling dot is still a hold point...
1855            assert_eq!(desc.marks.len(), 1, "r: {radius} — the dot must survive");
1856            assert_eq!(desc.marks[0].down_mil, 1.0);
1857            // ...and the ring is COUNTED as a decoration, not quietly absent.
1858            assert_eq!(
1859                report.dropped_element_types,
1860                vec![("circle".to_string(), 1)],
1861                "r: {radius} — the ring is counted under its own tag"
1862            );
1863            assert_eq!(report.dropped_elements, 1);
1864        }
1865    }
1866
1867    /// The same leniency on the angles — with the same SAYING SO. An arc whose `start`/`end`
1868    /// are not numbers has lost its sweep, and that is the same kind of loss as an unreadable
1869    /// radius: the document declared an arc this module cannot resolve. Calling it a plain
1870    /// ring would drop the declaration and report nothing, which is exactly the silence
1871    /// MBA-1441 is named for.
1872    #[test]
1873    fn a_declared_sweep_that_cannot_be_read_is_reported_not_quietly_a_ring() {
1874        for (start, end) in [
1875            (r#""200deg""#, r#"{"deg":340}"#),
1876            ("200", r#""340deg""#),
1877            (r#""200deg""#, "340"),
1878        ] {
1879            let json = format!(
1880                r#"{{"name":"A","unit":"mil","spec":[
1881                    {{"type":"dot","x":0,"y":1}},
1882                    {{"type":"circle","x":0,"y":0,"r":2,"start":{start},"end":{end}}}
1883                ]}}"#
1884            );
1885            let (desc, report) = import_ventum_reticle_with_report(&json)
1886                .unwrap_or_else(|e| panic!("{start}/{end} must not fail the import, got {e:?}"));
1887            assert_eq!(desc.marks.len(), 1, "{start}/{end} — the dot must survive");
1888            assert_eq!(
1889                report.dropped_element_types,
1890                vec![("arc".to_string(), 1)],
1891                "{start}/{end} — a declared sweep keeps the arc tag even when unreadable"
1892            );
1893            assert!(report.arcs.is_empty(), "{start}/{end} — nothing to resolve");
1894            assert_eq!(
1895                report.arcs_unresolved, 1,
1896                "{start}/{end} — an unreadable sweep is reported exactly like an unreadable \
1897                 radius, not swallowed"
1898            );
1899            assert_eq!(
1900                report.arcs.len() + report.arcs_unresolved,
1901                report.dropped_elements,
1902                "{start}/{end} — the stated arcs-vs-tally invariant still holds"
1903            );
1904        }
1905    }
1906
1907    /// One angle alone still describes no sweep, whatever type it is, so a `circle` carrying
1908    /// only `start` is a ring — and remains one when that lone angle is unreadable. This is
1909    /// the boundary of the rule above: an arc needs BOTH angles declared before there is a
1910    /// declaration to lose.
1911    #[test]
1912    fn a_lone_angle_is_a_ring_whether_or_not_it_can_be_read() {
1913        for spec in [
1914            r#"{"type":"circle","x":0,"y":0,"r":2,"start":200}"#,
1915            r#"{"type":"circle","x":0,"y":0,"r":2,"start":"200deg"}"#,
1916            r#"{"type":"circle","x":0,"y":0,"r":2,"end":340}"#,
1917        ] {
1918            let json = format!(r#"{{"name":"R","unit":"mil","spec":[{spec}]}}"#);
1919            let (_, report) = import_ventum_reticle_with_report(&json).unwrap();
1920            assert_eq!(
1921                report.dropped_element_types,
1922                vec![("circle".to_string(), 1)],
1923                "{spec} — one angle is no sweep"
1924            );
1925            assert_eq!(report.arcs_unresolved, 0, "{spec}");
1926        }
1927    }
1928
1929    /// An arc with real angles but an unreadable radius keeps its `arc` tag and lands in the
1930    /// stated-discrepancy bucket, exactly like one that omitted `r` altogether.
1931    #[test]
1932    fn an_arc_with_an_unreadable_radius_is_counted_as_unresolved() {
1933        let (_, report) = import_ventum_reticle_with_report(
1934            r#"{"name":"U","unit":"mil","spec":[
1935                {"type":"circle","x":0,"y":0,"r":"2mil","start":200,"end":340}
1936            ]}"#,
1937        )
1938        .unwrap();
1939        assert_eq!(report.dropped_element_types, vec![("arc".to_string(), 1)]);
1940        assert_eq!(report.arcs_unresolved, 1);
1941        assert!(report.arcs.is_empty());
1942        assert_eq!(
1943            report.arcs.len() + report.arcs_unresolved,
1944            report.dropped_elements
1945        );
1946    }
1947
1948    /// An ABSENT center — omitted, or `null`, which is how JSON spells absent — is the
1949    /// reticle center, and the arc resolves around it. That is the format's own default, not
1950    /// a guess.
1951    #[test]
1952    fn an_absent_or_null_center_is_the_reticle_center() {
1953        let (_, report) = import_ventum_reticle_with_report(
1954            r#"{"name":"C","unit":"mil","spec":[
1955                {"type":"circle","r":2,"start":200,"end":340},
1956                {"type":"circle","x":null,"y":null,"r":2,"start":200,"end":340}
1957            ]}"#,
1958        )
1959        .unwrap();
1960        assert_eq!(report.arcs.len(), 2);
1961        assert_eq!(report.arcs_unresolved, 0);
1962        assert_eq!(report.arcs[0].center, VentumArcPoint::default());
1963        assert_eq!(report.arcs[1].center, VentumArcPoint::default());
1964    }
1965
1966    /// A center the document WROTE and this module cannot read is a different thing entirely.
1967    /// Resolving the arc about the origin anyway would print an apex and two tips at
1968    /// coordinates nobody wrote, under a notice that invites the reader to add them as marks
1969    /// — inventing a hold, which is the other half of MBA-1441's complaint. So it is
1970    /// unresolved, reported beside an unreadable radius and an unreadable sweep.
1971    #[test]
1972    fn a_center_that_cannot_be_read_leaves_the_arc_unresolved() {
1973        for (x, y) in [
1974            (r#""left""#, "0"),
1975            ("0", r#""up""#),
1976            (r#"{"v":1}"#, r#"{"v":1}"#),
1977        ] {
1978            let json = format!(
1979                r#"{{"name":"C","unit":"mil","spec":[
1980                    {{"type":"circle","x":{x},"y":{y},"r":2,"start":200,"end":340}}
1981                ]}}"#
1982            );
1983            let (_, report) = import_ventum_reticle_with_report(&json)
1984                .unwrap_or_else(|e| panic!("x:{x} y:{y} must not fail the import, got {e:?}"));
1985            assert_eq!(
1986                report.dropped_element_types,
1987                vec![("arc".to_string(), 1)],
1988                "x:{x} y:{y} — still an arc"
1989            );
1990            assert!(
1991                report.arcs.is_empty(),
1992                "x:{x} y:{y} — no points may be reported from a center nobody wrote"
1993            );
1994            assert_eq!(report.arcs_unresolved, 1, "x:{x} y:{y}");
1995        }
1996    }
1997
1998    /// A ring is never resolved, so an unreadable center costs it nothing: it is counted as
1999    /// the plain `circle` it is, and no arc bucket moves.
2000    #[test]
2001    fn an_unreadable_center_on_a_plain_ring_is_just_a_counted_ring() {
2002        let (_, report) = import_ventum_reticle_with_report(
2003            r#"{"name":"C","unit":"mil","spec":[{"type":"circle","x":"left","r":2}]}"#,
2004        )
2005        .unwrap();
2006        assert_eq!(
2007            report.dropped_element_types,
2008            vec![("circle".to_string(), 1)]
2009        );
2010        assert_eq!(report.arcs_unresolved, 0);
2011        assert!(report.arcs.is_empty());
2012
2013        // It is not entirely without consequence, though, and the consequence is a count.
2014        // Mirroring a ring across the axis it sits on normally dedupes the twin away; an
2015        // unreadable center makes the stepped coordinate a fallback, which proves nothing, so
2016        // the twin is kept and the document is credited with the two shapes it drew.
2017        let (_, mirrored) = import_ventum_reticle_with_report(
2018            r#"{"name":"C","unit":"mil","spec":[
2019                {"type":"circle","x":"left","y":0,"r":2,
2020                 "repeat":{"axis":"x","step":0,"n":1,"mirror":true}}
2021            ]}"#,
2022        )
2023        .unwrap();
2024        assert_eq!(
2025            mirrored.dropped_element_types,
2026            vec![("circle".to_string(), 2)],
2027            "an unprovable dedupe keeps the twin, ring or not"
2028        );
2029
2030        // The same ring with a center this module CAN read is one shape, as it always was.
2031        let (_, readable) = import_ventum_reticle_with_report(
2032            r#"{"name":"C","unit":"mil","spec":[
2033                {"type":"circle","x":0,"y":0,"r":2,
2034                 "repeat":{"axis":"x","step":0,"n":1,"mirror":true}}
2035            ]}"#,
2036        )
2037        .unwrap();
2038        assert_eq!(
2039            readable.dropped_element_types,
2040            vec![("circle".to_string(), 1)]
2041        );
2042    }
2043
2044    /// Leniency must not cost a real arc its geometry: a genuine numeric circle in the SAME
2045    /// document as a cosmetic one still resolves to a drawn arc, apex and tips included.
2046    #[test]
2047    fn a_numeric_arc_still_resolves_beside_a_cosmetic_one() {
2048        let (desc, report) = import_ventum_reticle_with_report(
2049            r#"{"name":"H","unit":"mil","spec":[
2050                {"type":"dot","x":0,"y":1},
2051                {"type":"circle","x":0,"y":0,"r":"2mil"},
2052                {"type":"circle","x":0,"y":0,"r":2,"start":200,"end":340}
2053            ]}"#,
2054        )
2055        .unwrap();
2056        assert_eq!(
2057            desc.marks.len(),
2058            1,
2059            "the dot is unaffected by either circle"
2060        );
2061        assert_eq!(
2062            report.dropped_element_types,
2063            vec![("arc".to_string(), 1), ("circle".to_string(), 1)],
2064            "the cosmetic ring and the real horseshoe are counted apart"
2065        );
2066        assert_eq!(report.dropped_elements, 2);
2067
2068        // The real horseshoe is fully resolved: radius, sweep, and an apex straight up.
2069        assert_eq!(report.arcs.len(), 1);
2070        assert_eq!(report.arcs_unresolved, 0);
2071        let arc = report.arcs[0];
2072        assert_eq!(arc.radius_mil, 2.0);
2073        assert!((arc.sweep_degrees - 140.0).abs() < 1e-9);
2074        assert!(
2075            (arc.apex.down_mil + 2.0).abs() < 1e-9 && arc.apex.right_mil.abs() < 1e-9,
2076            "apex at 270 deg is 2 mil straight above center, got {:?}",
2077            arc.apex
2078        );
2079    }
2080
2081    /// A `circle`'s `repeat` is the sixth cosmetic field and is read like the other five: a
2082    /// repeat this module cannot use is the same answer as no repeat, which is what 0.32.0
2083    /// gave every circle in every document. Each of these spellings imported under 0.32.0 —
2084    /// `Circle {}` read no fields at all — so each must import now.
2085    #[test]
2086    fn an_unusable_repeat_on_a_circle_is_ignored_rather_than_rejected() {
2087        for repeat in [
2088            r#"{"axis":"x","step":"1mil","n":1}"#,
2089            r#"{"axis":"x","step":1,"n":"two"}"#,
2090            r#"{"axis":"diagonal","step":1,"n":1}"#,
2091            r#"{"step":1,"n":1}"#,
2092            r#"{"axis":"x","step":1,"n":1,"mirror":"yes"}"#,
2093            r#""every 1 mil""#,
2094            "[1]",
2095            "7",
2096        ] {
2097            let json = format!(
2098                r#"{{"name":"L","unit":"mil","spec":[
2099                    {{"type":"dot","x":0,"y":1}},
2100                    {{"type":"circle","x":0,"y":0,"r":2,"repeat":{repeat}}}
2101                ]}}"#
2102            );
2103            let (desc, report) = import_ventum_reticle_with_report(&json)
2104                .unwrap_or_else(|e| panic!("repeat {repeat} must not fail the import, got {e:?}"));
2105
2106            // The document imported: the sibling dot is still a hold point...
2107            assert_eq!(
2108                desc.marks.len(),
2109                1,
2110                "repeat {repeat} — the dot must survive"
2111            );
2112            assert_eq!(desc.marks[0].down_mil, 1.0);
2113            // ...the ring is counted once, as an unrepeated circle...
2114            assert_eq!(
2115                report.dropped_element_types,
2116                vec![("circle".to_string(), 1)],
2117                "repeat {repeat} — expanded as though it had no repeat"
2118            );
2119            // ...and the tally says it may be low, rather than passing itself off as exact.
2120            assert_eq!(
2121                report.circle_repeats_unreadable, 1,
2122                "repeat {repeat} — an unreadable repeat must be declared, not swallowed"
2123            );
2124        }
2125    }
2126
2127    /// A `repeat` a mark carries stays STRICT, because it stamps hold points: degrading it to
2128    /// one copy would drop marks in silence. Leniency is the circle's, not [`Repeat`]'s.
2129    #[test]
2130    fn an_unusable_repeat_on_a_hold_bearing_mark_still_fails_loudly() {
2131        for kind in [
2132            r#"{"type":"dot","x":0,"y":1"#,
2133            r#"{"type":"tick","x":0,"y":1"#,
2134            r#"{"type":"text","x":0,"y":1,"text":"5""#,
2135        ] {
2136            let json = format!(
2137                r#"{{"name":"S","unit":"mil","spec":[
2138                    {kind},"repeat":{{"axis":"x","step":"1mil","n":3}}}}
2139                ]}}"#
2140            );
2141            let error = import_ventum_reticle(&json)
2142                .expect_err("a mark's malformed repeat must not be swallowed");
2143            assert!(
2144                matches!(error, ReticleError::InvalidSpec(_)),
2145                "{kind} — expected InvalidSpec, got {error:?}"
2146            );
2147        }
2148    }
2149
2150    /// A readable `repeat` on a circle is untouched by that leniency: it still stamps its
2151    /// copies, and the report still counts every one of them.
2152    #[test]
2153    fn a_readable_repeat_on_a_circle_still_stamps_its_copies() {
2154        let (_, report) = import_ventum_reticle_with_report(
2155            r#"{"name":"L","unit":"mil","spec":[
2156                {"type":"circle","x":1,"y":0,"r":0.5,"repeat":{"axis":"x","step":1,"n":4}}
2157            ]}"#,
2158        )
2159        .unwrap();
2160        assert_eq!(
2161            report.dropped_element_types,
2162            vec![("circle".to_string(), 4)]
2163        );
2164        assert_eq!(report.circle_repeats_unreadable, 0);
2165    }
2166
2167    /// The mirror dedupe drops a twin only when it can PROVE the twin is a duplicate, and
2168    /// geometry the document wrote but this module cannot read proves nothing. A centered arc
2169    /// whose sweep or center is unreadable therefore keeps its twin: both are counted, both
2170    /// are unresolved, and the caller is told two shapes were lost. Guessing "duplicate" would
2171    /// undercount in exactly the way this report exists to prevent.
2172    #[test]
2173    fn an_unreadable_centered_arc_keeps_its_mirrored_twin() {
2174        // start + end = 540 = 180 (mod 360) reads as symmetric about the vertical axis and
2175        // would be deduped to one — but only when the angles can be read at all.
2176        for spec in [
2177            r#""start":"200deg","end":340"#,
2178            r#""start":200,"end":"340deg""#,
2179        ] {
2180            let json = format!(
2181                r#"{{"name":"U","unit":"mil","spec":[
2182                    {{"type":"circle","x":0,"y":0,"r":2,{spec},
2183                     "repeat":{{"axis":"x","step":1,"n":1,"mirror":true}}}}
2184                ]}}"#
2185            );
2186            let (_, report) = import_ventum_reticle_with_report(&json).unwrap();
2187            assert_eq!(
2188                report.dropped_element_types,
2189                vec![("arc".to_string(), 2)],
2190                "{spec} — an unprovable dedupe keeps the twin"
2191            );
2192            assert_eq!(report.arcs_unresolved, 2, "{spec}");
2193            assert!(report.arcs.is_empty(), "{spec}");
2194        }
2195
2196        // Same for a center that cannot be read: the stepped coordinate this dedupe tests is
2197        // itself the fallback, so it proves nothing either.
2198        let (_, report) = import_ventum_reticle_with_report(
2199            r#"{"name":"U","unit":"mil","spec":[
2200                {"type":"circle","x":"left","y":0,"r":2,"start":200,"end":340,
2201                 "repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
2202            ]}"#,
2203        )
2204        .unwrap();
2205        assert_eq!(report.dropped_element_types, vec![("arc".to_string(), 2)]);
2206        assert_eq!(report.arcs_unresolved, 2);
2207    }
2208
2209    /// KNOWN DIVERGENCE from 0.32.0, recorded so it is a fact rather than a surprise.
2210    ///
2211    /// Resolving arcs put `circle` instances through repeat expansion, and expansion enforces
2212    /// [`MAX_RETICLE_MARKS`] over everything it materializes. A `circle` therefore consumes
2213    /// mark budget it never consumed before — under 0.32.0 it was skipped before expansion
2214    /// began — so a document already at the cap, or one whose circle carries a huge
2215    /// `repeat.n`, is refused where 0.32.0 imported it. Leniency cannot reach this: the
2216    /// document is well-formed, it is the cap that refuses it. Both CHANGELOG and CLI_USAGE
2217    /// state the exception; this pins it.
2218    #[test]
2219    fn a_circle_counts_against_the_mark_cap_unlike_0_32_0() {
2220        // One dot short of the cap, plus one ring: fine, because the ring is the last slot.
2221        let at_cap = format!(
2222            r#"{{"name":"C","unit":"mil","spec":[
2223                {{"type":"dot","x":1,"y":1,"repeat":{{"axis":"x","step":0.001,"n":{}}}}},
2224                {{"type":"circle","x":0,"y":0,"r":1}}
2225            ]}}"#,
2226            MAX_RETICLE_MARKS - 1
2227        );
2228        let (desc, report) = import_ventum_reticle_with_report(&at_cap).unwrap();
2229        assert_eq!(desc.marks.len(), MAX_RETICLE_MARKS - 1);
2230        assert_eq!(
2231            report.dropped_element_types,
2232            vec![("circle".to_string(), 1)]
2233        );
2234
2235        // A full cap of marks plus one ring: refused, where 0.32.0 imported the marks and
2236        // dropped the ring unexamined.
2237        let over_cap = format!(
2238            r#"{{"name":"C","unit":"mil","spec":[
2239                {{"type":"dot","x":1,"y":1,"repeat":{{"axis":"x","step":0.001,"n":{MAX_RETICLE_MARKS}}}}},
2240                {{"type":"circle","x":0,"y":0,"r":1}}
2241            ]}}"#
2242        );
2243        assert!(
2244            matches!(
2245                import_ventum_reticle(&over_cap),
2246                Err(ReticleError::TooManyMarks { .. })
2247            ),
2248            "a circle past the cap is refused — the documented 0.32.0 divergence"
2249        );
2250    }
2251
2252    /// A CENTERED asymmetric arc with `mirror:true` draws two different shapes, because
2253    /// reflection reworks the angles as well as the center. Both must be counted, and their
2254    /// apexes must land on opposite sides — reporting one would be the undercount MBA-1441
2255    /// exists to prevent.
2256    #[test]
2257    fn a_centered_asymmetric_arc_still_gets_its_mirrored_twin() {
2258        let (_, report) = import_ventum_reticle_with_report(
2259            r#"{"name":"A","unit":"mil","spec":[
2260                {"type":"circle","x":0,"y":0,"r":2,"start":290,"end":70,
2261                 "repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
2262            ]}"#,
2263        )
2264        .unwrap();
2265        assert_eq!(report.dropped_element_types, vec![("arc".to_string(), 2)]);
2266        assert_eq!(report.arcs.len(), 2, "the centered twin is a second shape");
2267
2268        // Original apex is at 290 + 140/2 = 0 deg, i.e. 2 mil RIGHT of center; the twin's is
2269        // at 180 deg, 2 mil LEFT. Same center, same sweep, opposite sides.
2270        let (first, second) = (report.arcs[0], report.arcs[1]);
2271        assert!(
2272            (first.apex.right_mil - 2.0).abs() < 1e-9,
2273            "{:?}",
2274            first.apex
2275        );
2276        assert!(
2277            (second.apex.right_mil + 2.0).abs() < 1e-9,
2278            "{:?}",
2279            second.apex
2280        );
2281        assert!((first.sweep_degrees - second.sweep_degrees).abs() < 1e-9);
2282        assert_eq!(first.center, second.center);
2283    }
2284
2285    /// The dedupe is not abandoned, only narrowed to what it was always for. An arc that the
2286    /// reflection maps onto ITSELF — a horseshoe centered on the axis it is mirrored across,
2287    /// or any ring — is still one shape and is still counted once.
2288    #[test]
2289    fn a_centered_symmetric_arc_is_still_deduped() {
2290        // start 200 + end 340 = 540 = 180 (mod 360): symmetric about the vertical axis.
2291        let (_, horseshoe) = import_ventum_reticle_with_report(
2292            r#"{"name":"S","unit":"mil","spec":[
2293                {"type":"circle","x":0,"y":0,"r":2,"start":200,"end":340,
2294                 "repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
2295            ]}"#,
2296        )
2297        .unwrap();
2298        assert_eq!(
2299            horseshoe.dropped_element_types,
2300            vec![("arc".to_string(), 1)]
2301        );
2302        assert_eq!(horseshoe.arcs.len(), 1);
2303
2304        // ...and a plain ring maps onto itself under any reflection through its own center.
2305        let (_, ring) = import_ventum_reticle_with_report(
2306            r#"{"name":"R","unit":"mil","spec":[
2307                {"type":"circle","x":0,"y":0,"r":2,
2308                 "repeat":{"axis":"y","step":1,"n":1,"mirror":true}}
2309            ]}"#,
2310        )
2311        .unwrap();
2312        assert_eq!(ring.dropped_element_types, vec![("circle".to_string(), 1)]);
2313
2314        // The SAME horseshoe mirrored across the HORIZONTAL axis is not symmetric about it —
2315        // it opens the other way — so that one is two shapes.
2316        let (_, flipped) = import_ventum_reticle_with_report(
2317            r#"{"name":"F","unit":"mil","spec":[
2318                {"type":"circle","x":0,"y":0,"r":2,"start":200,"end":340,
2319                 "repeat":{"axis":"y","step":1,"n":1,"mirror":true}}
2320            ]}"#,
2321        )
2322        .unwrap();
2323        assert_eq!(flipped.dropped_element_types, vec![("arc".to_string(), 2)]);
2324        assert!(
2325            (flipped.arcs[0].apex.down_mil + 2.0).abs() < 1e-9,
2326            "apex up"
2327        );
2328        assert!(
2329            (flipped.arcs[1].apex.down_mil - 2.0).abs() < 1e-9,
2330            "twin apex down"
2331        );
2332    }
2333
2334    /// The centered-arc dedupe decision, stated as a table, for the two horseshoes this
2335    /// module's documentation argues from across both mirror axes.
2336    ///
2337    /// [`CircleShape::is_reflection_of_itself`] is a congruence on `start + end`, so it does
2338    /// not care how the angles are spelled — but MBA-1536 changes how a twin's angles ARE
2339    /// spelled, and "the dedupe is unaffected" is the kind of claim that should be driven
2340    /// rather than reasoned. This table is what would fail if the two ever coupled.
2341    #[test]
2342    fn the_centered_mirror_dedupe_table() {
2343        // (start, end, axis, how many shapes the document drew).
2344        //
2345        // 200 + 340 = 540 ≡ 180: symmetric about the VERTICAL axis (`x`), not the horizontal.
2346        // 290 + 70  = 360 ≡ 0:   symmetric about the HORIZONTAL axis (`y`), not the vertical.
2347        // So each horseshoe dedupes on exactly one axis, and they disagree about which.
2348        let table = [
2349            (200, 340, "x", 1),
2350            (200, 340, "y", 2),
2351            (290, 70, "x", 2),
2352            (290, 70, "y", 1),
2353        ];
2354        for (start, end, axis, expected) in table {
2355            let (_, report) = import_ventum_reticle_with_report(&format!(
2356                r#"{{"name":"T","unit":"mil","spec":[
2357                    {{"type":"circle","x":0,"y":0,"r":2,"start":{start},"end":{end},
2358                     "repeat":{{"axis":"{axis}","step":1,"n":1,"mirror":true}}}}
2359                ]}}"#
2360            ))
2361            .unwrap();
2362            assert_eq!(
2363                report.arcs.len(),
2364                expected,
2365                "start {start}, end {end}, mirrored across {axis}"
2366            );
2367            assert_eq!(
2368                report.dropped_element_types,
2369                vec![("arc".to_string(), expected)],
2370                "the tally must agree with the arcs for start {start}, end {end} across {axis}"
2371            );
2372        }
2373    }
2374
2375    /// MBA-1536: a mirrored twin's REPORTED angles are degrees clockwise from 3 o'clock, like
2376    /// every other arc's, rather than the raw output of `180 - theta` or `-theta`.
2377    ///
2378    /// The twin of a `start: 290, end: 70` horseshoe reflects across the vertical axis to
2379    /// `(110, -110)`, which the CLI printed as `arc 110--110 deg` — two dashes, and a negative
2380    /// number in a field documented as a clockwise bearing. Reflection is unchanged; only the
2381    /// spelling of what comes out of it is.
2382    #[test]
2383    fn a_mirrored_arcs_reported_angles_are_clockwise_bearings() {
2384        // Across the vertical axis: (290, 70) -> (180-70, 180-290) = (110, -110) -> (110, 250).
2385        let (_, across_x) = import_ventum_reticle_with_report(
2386            r#"{"name":"N","unit":"mil","spec":[
2387                {"type":"circle","x":3,"y":0,"r":2,"start":290,"end":70,
2388                 "repeat":{"axis":"x","step":1,"n":1,"mirror":true}}
2389            ]}"#,
2390        )
2391        .unwrap();
2392        let twin = across_x
2393            .arcs
2394            .iter()
2395            .find(|a| a.center.right_mil < 0.0)
2396            .expect("the mirrored twin");
2397        assert!((twin.start_degrees - 110.0).abs() < 1e-9, "{twin:?}");
2398        assert!((twin.end_degrees - 250.0).abs() < 1e-9, "{twin:?}");
2399        // Same arc as before the reduction: same extent, and the apex still on the far side.
2400        assert!((twin.sweep_degrees - 140.0).abs() < 1e-9, "{twin:?}");
2401        assert!((twin.apex.right_mil + 5.0).abs() < 1e-9, "{:?}", twin.apex);
2402
2403        // Across the horizontal axis: (200, 340) -> (-340, -200) -> (20, 160).
2404        let (_, across_y) = import_ventum_reticle_with_report(
2405            r#"{"name":"N","unit":"mil","spec":[
2406                {"type":"circle","x":0,"y":3,"r":2,"start":200,"end":340,
2407                 "repeat":{"axis":"y","step":1,"n":1,"mirror":true}}
2408            ]}"#,
2409        )
2410        .unwrap();
2411        let twin = across_y
2412            .arcs
2413            .iter()
2414            .find(|a| a.center.down_mil < 0.0)
2415            .expect("the mirrored twin");
2416        assert!((twin.start_degrees - 20.0).abs() < 1e-9, "{twin:?}");
2417        assert!((twin.end_degrees - 160.0).abs() < 1e-9, "{twin:?}");
2418        assert!((twin.sweep_degrees - 140.0).abs() < 1e-9, "{twin:?}");
2419
2420        // Every arc either import reported is a clockwise bearing, twin or original.
2421        for arc in across_x.arcs.iter().chain(across_y.arcs.iter()) {
2422            for angle in [arc.start_degrees, arc.end_degrees] {
2423                assert!(
2424                    (0.0..360.0).contains(&angle),
2425                    "{angle} is not a clockwise bearing ({arc:?})"
2426                );
2427            }
2428        }
2429    }
2430
2431    /// An arc the document wrote itself is reported AS WRITTEN. The reduction above is about
2432    /// angles this module computed, not about rewriting the document's own numbers.
2433    #[test]
2434    fn an_unmirrored_arcs_angles_are_the_documents_own() {
2435        let (_, report) = import_ventum_reticle_with_report(
2436            r#"{"name":"D","unit":"mil","spec":[
2437                {"type":"circle","x":0,"y":0,"r":2,"start":-110,"end":110}
2438            ]}"#,
2439        )
2440        .unwrap();
2441        let arc = report.arcs[0];
2442        assert_eq!(arc.start_degrees, -110.0);
2443        assert_eq!(arc.end_degrees, 110.0);
2444        assert!((arc.sweep_degrees - 220.0).abs() < 1e-9);
2445    }
2446
2447    /// [`normalize_degrees`] lands in `[0, 360)` — including the rounding case its own comment
2448    /// claims, where `rem_euclid` returns exactly 360.0 for an input just below zero.
2449    #[test]
2450    fn normalize_degrees_lands_in_range() {
2451        let cases = [
2452            0.0,
2453            -0.0,
2454            360.0,
2455            -360.0,
2456            110.0,
2457            -110.0,
2458            -1e-16,
2459            -1e-300,
2460            f64::MIN_POSITIVE,
2461            1e12,
2462            -1e12,
2463        ];
2464        for input in cases {
2465            let out = normalize_degrees(input);
2466            assert!(
2467                (0.0..360.0).contains(&out),
2468                "normalize_degrees({input}) = {out}, outside [0, 360)"
2469            );
2470        }
2471        assert_eq!(normalize_degrees(-110.0), 250.0);
2472        assert_eq!(normalize_degrees(360.0), 0.0);
2473        // The guard earns its place: raw `rem_euclid` does round this one up to 360.0.
2474        assert_eq!((-1e-16f64).rem_euclid(360.0), 360.0);
2475        assert_eq!(normalize_degrees(-1e-16), 0.0);
2476        // Not an angle, and not turned into one.
2477        assert!(normalize_degrees(f64::NAN).is_nan());
2478        assert_eq!(normalize_degrees(f64::INFINITY), f64::INFINITY);
2479    }
2480
2481    // -----------------------------------------------------------------------------------
2482    // The strictness claims, pinned.
2483    //
2484    // The module's serde section, the CHANGELOG and CLI_USAGE all say the same thing about
2485    // this importer in prose — which fields can refuse a document, which absorb anything,
2486    // and what an arc needs before its points are reported. Prose drifts; three separate
2487    // rounds of review caught a sentence here that had gone one member out of date. These
2488    // sweeps are what those sentences point at: each states its claim as code, then drives
2489    // every case it covers through the real importer, so a change to the model that
2490    // contradicts the prose turns a test red instead of leaving a lie in a document.
2491    // -----------------------------------------------------------------------------------
2492
2493    /// Values the Ventum schema declares no field as. `null` is deliberately absent: where it
2494    /// is read as absence rather than as a bad value, and where it is not, is pinned by
2495    /// [`null_is_read_as_absence_not_as_a_bad_value`] rather than described here.
2496    const WRONG_TYPED: [&str; 4] = [r#""a string""#, r#"{"nested":1}"#, "[1]", "true"];
2497
2498    /// [`WRONG_TYPED`] minus anything that is in fact the right type for `key`. Among the
2499    /// ELEMENT keys this sweep drives, `text` is the one that takes a string, so a string is
2500    /// a perfectly good value there and is not evidence of anything; the rest take a number
2501    /// or the `repeat` object. (Keys outside this sweep do take strings — the `type` tag,
2502    /// `name`, `plane`, `unit`, a `repeat`'s `axis`, and the format's ignored cosmetic keys
2503    /// such as `color` and `notes`. The sweep does not turn on any of their types.) (An
2504    /// object IS the right shape for a `repeat`, but `{"nested":1}` is missing every field
2505    /// one needs, so it is still a value a strict `repeat` refuses.)
2506    fn wrong_typed_for(key: &str) -> Vec<&'static str> {
2507        WRONG_TYPED
2508            .into_iter()
2509            .filter(|value| !(key == "text" && value.starts_with('"')))
2510            .collect()
2511    }
2512
2513    /// Every key this importer reads on any element — the union across the variants — plus
2514    /// two cosmetic ones it never reads, so the sweeps cover "a key this element does not
2515    /// have" as well as "a key it does". [`strictness_is_exactly_what_a_hold_is_built_from`]
2516    /// asserts that nothing [`is_strict`] names is missing from this list.
2517    const EVERY_ELEMENT_KEY: [&str; 11] = [
2518        "x",
2519        "y",
2520        "cx",
2521        "cy",
2522        "r",
2523        "start",
2524        "end",
2525        "text",
2526        "repeat",
2527        "color",
2528        "max_extent",
2529    ];
2530
2531    /// The element types this model can produce, taken FROM the model instead of from
2532    /// memory: the match below is exhaustive, so adding a variant to [`Element`] stops this
2533    /// file compiling until the new type is named — and once named it is swept with every
2534    /// key above.
2535    ///
2536    /// What the guard does not do, stated so nobody trusts it further than it goes: the
2537    /// sample list it maps over is hand-written, so a new variant needs adding in two places
2538    /// rather than one. The compile error lands on the match, three lines from the list.
2539    fn every_element_type() -> Vec<&'static str> {
2540        let samples = [
2541            Element::Dot {
2542                x: 0.0,
2543                y: 0.0,
2544                repeat: None,
2545            },
2546            Element::Tick {
2547                x: 0.0,
2548                y: 0.0,
2549                repeat: None,
2550            },
2551            Element::Text {
2552                x: 0.0,
2553                y: 0.0,
2554                text: String::new(),
2555                repeat: None,
2556            },
2557            Element::Line {},
2558            Element::Circle(CircleFields::default()),
2559            Element::Rect {},
2560            Element::Grid {},
2561            Element::Unknown,
2562        ];
2563        samples
2564            .iter()
2565            .map(|element| match element {
2566                Element::Dot { .. } => "dot",
2567                Element::Tick { .. } => "tick",
2568                Element::Text { .. } => "text",
2569                Element::Line {} => "line",
2570                Element::Circle(_) => "circle",
2571                Element::Rect {} => "rect",
2572                Element::Grid {} => "grid",
2573                // The schema defines no such type; `Unknown` is this model's answer for
2574                // anything it has never heard of, so the sweeps drive it with a name no
2575                // schema will define.
2576                Element::Unknown => "a_type_this_module_has_never_heard_of",
2577            })
2578            .collect()
2579    }
2580
2581    /// The keys that make an element of `element_type` well-formed, so a sweep changing one
2582    /// key at a time is testing that key rather than a missing sibling.
2583    fn valid_base(element_type: &str) -> &'static [(&'static str, &'static str)] {
2584        match element_type {
2585            "dot" | "tick" => &[("x", "0"), ("y", "0")],
2586            "text" => &[("x", "0"), ("y", "0"), ("text", "\"t\"")],
2587            "circle" => &[("r", "1")],
2588            _ => &[],
2589        }
2590    }
2591
2592    /// THE CLAIM, written where a test can disagree with it: the (element type, key) pairs
2593    /// that refuse a whole document over a value of the wrong type. Every other pair absorbs
2594    /// one.
2595    ///
2596    /// This is the ELEMENT-KEY half of what the module's serde section says in words —
2597    /// strict is what a hold is built from: a `dot`/`tick` position, a `text`'s own position
2598    /// and string, and the `repeat` that stamps copies of any of those. The section also
2599    /// names the `type` tag, which is not an (element type, key) pair and is pinned at the
2600    /// end of the sweep instead.
2601    fn is_strict(element_type: &str, key: &str) -> bool {
2602        matches!(
2603            (element_type, key),
2604            ("dot" | "tick", "x" | "y" | "repeat") | ("text", "x" | "y" | "text" | "repeat")
2605        )
2606    }
2607
2608    /// A one-element document of `element_type`, well-formed except that `key` holds `value`
2609    /// (raw JSON). A base key of the same name is replaced rather than repeated, so the
2610    /// document tests the value's type and not a duplicate key.
2611    fn one_element_document(element_type: &str, key: &str, value: &str) -> String {
2612        let mut keys: Vec<String> = vec![format!(r#""type":"{element_type}""#)];
2613        for (base_key, base_value) in valid_base(element_type) {
2614            if *base_key != key {
2615                keys.push(format!(r#""{base_key}":{base_value}"#));
2616            }
2617        }
2618        keys.push(format!(r#""{key}":{value}"#));
2619        format!(
2620            r#"{{"name":"S","unit":"mil","spec":[{{{}}}]}}"#,
2621            keys.join(",")
2622        )
2623    }
2624
2625    /// Every element type this model knows, crossed with every key the schema puts on one,
2626    /// crossed with values the schema never allows: the document must be refused for exactly
2627    /// the pairs [`is_strict`] names and imported for all the rest.
2628    ///
2629    /// Both directions matter and both are asserted by the same line. Loosening a mark's
2630    /// field turns this red (a refusal that stopped happening); tightening any field of a
2631    /// `circle`, or adding a typed one to it, turns it red too (a refusal that started).
2632    #[test]
2633    fn strictness_is_exactly_what_a_hold_is_built_from() {
2634        let mut refusals = 0usize;
2635        let mut acceptances = 0usize;
2636        for element_type in every_element_type() {
2637            for key in EVERY_ELEMENT_KEY {
2638                for value in wrong_typed_for(key) {
2639                    let json = one_element_document(element_type, key, value);
2640                    let refused = import_ventum_reticle(&json).is_err();
2641                    let expected = is_strict(element_type, key);
2642                    assert_eq!(
2643                        refused, expected,
2644                        "{element_type}.{key} = {value}: the importer refused={refused}, the \
2645                         documented boundary says strict={expected}\n  {json}"
2646                    );
2647                    if expected {
2648                        refusals += 1;
2649                    } else {
2650                        acceptances += 1;
2651                    }
2652                }
2653            }
2654        }
2655        // A sweep that swept nothing, or only one side of the boundary, would pass silently.
2656        // `dot` and `tick` each contribute x, y and repeat against four wrong values; `text`
2657        // those three plus its own string, which has only three wrong values because a
2658        // string is the right type for it.
2659        assert_eq!(refusals, 12 + 12 + 15, "the strict side must be covered");
2660        assert!(acceptances > 100, "the lenient side must be covered too");
2661
2662        // And nothing the claim names may sit outside the keys the sweep drives, or it would
2663        // be asserted about nothing at all.
2664        for element_type in every_element_type() {
2665            for key in [
2666                "x", "y", "cx", "cy", "r", "start", "end", "text", "repeat", "label",
2667            ] {
2668                assert!(
2669                    !is_strict(element_type, key) || EVERY_ELEMENT_KEY.contains(&key),
2670                    "{element_type}.{key} is claimed strict but is not in the swept key list"
2671                );
2672            }
2673        }
2674
2675        // The `type` tag cannot be swept the way the keys above are, because the sweep builds
2676        // its documents FROM a type — there is no element to put a wrong-typed key on until
2677        // one has been chosen. It is strict all the same, and the boundary paragraph names it
2678        // as such, so it is pinned here rather than left as a claim about nothing.
2679        for document in [
2680            r#"{"name":"N","unit":"mil","spec":[{"x":0,"y":1}]}"#,
2681            r#"{"name":"N","unit":"mil","spec":[{"type":null,"x":0,"y":1}]}"#,
2682            r#"{"name":"N","unit":"mil","spec":[{"type":7,"x":0,"y":1}]}"#,
2683        ] {
2684            assert!(
2685                import_ventum_reticle(document).is_err(),
2686                "an element whose `type` is missing or is not a STRING must refuse the \
2687                 document — leniency is per-variant, and there is no variant yet. A type \
2688                 that IS a string but names no variant is a different case: `#[serde(other)]` \
2689                 reads it as `Element::Unknown` and it is dropped as decoration, which \
2690                 `every_element_type` drives deliberately.\n  {document}"
2691            );
2692        }
2693    }
2694
2695    /// The other direction, at the one element the leniency is about, with the two shapes a
2696    /// type sweep cannot reach: a key written twice, and the schema's two spellings of one
2697    /// center used together.
2698    ///
2699    /// Both of those refuse a derived struct with `duplicate field \`x\``, and 0.32.0
2700    /// accepted them because its `circle` read no keys at all. A ring must not be able to
2701    /// fail a document over how it spells a center, so [`CircleFields`] reads the map itself.
2702    #[test]
2703    fn no_circle_key_refuses_a_document_however_it_is_spelled_or_repeated() {
2704        const CIRCLE_KEYS: [&str; 8] = ["x", "y", "cx", "cy", "r", "start", "end", "repeat"];
2705        let values: Vec<&str> = WRONG_TYPED
2706            .iter()
2707            .copied()
2708            .chain(["null", "0", "-1", "1e308"])
2709            .collect();
2710
2711        for key in CIRCLE_KEYS {
2712            for value in &values {
2713                // Once, twice, and — for a center — beside its other spelling.
2714                let mut circles = vec![
2715                    format!(r#"{{"type":"circle","{key}":{value}}}"#),
2716                    format!(r#"{{"type":"circle","{key}":{value},"{key}":{value}}}"#),
2717                ];
2718                if let Some(other) = match key {
2719                    "x" => Some("cx"),
2720                    "cx" => Some("x"),
2721                    "y" => Some("cy"),
2722                    "cy" => Some("y"),
2723                    _ => None,
2724                } {
2725                    circles.push(format!(
2726                        r#"{{"type":"circle","{other}":1,"{key}":{value}}}"#
2727                    ));
2728                    circles.push(format!(
2729                        r#"{{"type":"circle","{key}":{value},"{other}":1}}"#
2730                    ));
2731                }
2732
2733                for circle in circles {
2734                    let json = format!(
2735                        r#"{{"name":"L","unit":"mil","spec":[
2736                            {{"type":"dot","x":0,"y":1}},{circle}
2737                        ]}}"#
2738                    );
2739                    let (desc, report) = import_ventum_reticle_with_report(&json)
2740                        .unwrap_or_else(|e| panic!("{circle} must not fail the import: {e:?}"));
2741                    assert_eq!(desc.marks.len(), 1, "{circle} — the dot must survive");
2742                    assert_eq!(
2743                        report.dropped_elements, 1,
2744                        "{circle} — the ring must still be counted, not silently absorbed"
2745                    );
2746                }
2747            }
2748        }
2749    }
2750
2751    /// And the rule that resolves a repeated or double-spelled key: the LAST one in document
2752    /// order wins, which is the answer `serde_json::Value` itself gives a repeated key.
2753    #[test]
2754    fn a_repeated_circle_key_takes_its_last_value() {
2755        // `x` and `cx` are one field, so the later spelling is the one that counts...
2756        for (spec, expected_right) in [
2757            (r#""x":5,"cx":1"#, 1.0),
2758            (r#""cx":1,"x":5"#, 5.0),
2759            (r#""x":5,"x":1"#, 1.0),
2760        ] {
2761            let json = format!(
2762                r#"{{"name":"O","unit":"mil","spec":[
2763                    {{"type":"circle",{spec},"y":0,"r":2,"start":200,"end":340}}
2764                ]}}"#
2765            );
2766            let (_, report) = import_ventum_reticle_with_report(&json).unwrap();
2767            assert_eq!(report.arcs.len(), 1, "{spec}");
2768            assert!(
2769                (report.arcs[0].center.right_mil - expected_right).abs() < 1e-12,
2770                "{spec}: center {} should be {expected_right}",
2771                report.arcs[0].center.right_mil
2772            );
2773        }
2774
2775        // ...and the same for a repeat, whose last value here is `null` — no repeat at all,
2776        // so one ring is counted rather than three.
2777        let (_, report) = import_ventum_reticle_with_report(
2778            r#"{"name":"O","unit":"mil","spec":[
2779                {"type":"circle","r":2,"repeat":{"axis":"x","step":1,"n":3},"repeat":null}
2780            ]}"#,
2781        )
2782        .unwrap();
2783        assert_eq!(
2784            report.dropped_element_types,
2785            vec![("circle".to_string(), 1)]
2786        );
2787        assert_eq!(
2788            report.circle_repeats_unreadable, 0,
2789            "`null` is absence, not garbage"
2790        );
2791    }
2792
2793    /// What an arc's points are built from, and therefore what leaves one unresolved.
2794    ///
2795    /// [`CircleShape::resolve`] needs three things and computes nothing without all of them:
2796    /// a center it could read, a radius that is a positive length, and a sweep. This drives
2797    /// each of the three away in turn — including the case an earlier version of the
2798    /// `arcs_unresolved` doc comment left out, a radius that reads perfectly well as a
2799    /// number and is not a length (`0`, or negative).
2800    #[test]
2801    fn an_arc_resolves_only_from_a_center_a_positive_radius_and_a_sweep() {
2802        // The positive control: all three readable, so the arc is resolved rather than
2803        // counted as lost. Without this the sweep below could pass on an importer that
2804        // resolved nothing at all.
2805        let (_, resolved) = import_ventum_reticle_with_report(
2806            r#"{"name":"A","unit":"mil","spec":[
2807                {"type":"circle","x":0,"y":0,"r":2,"start":200,"end":340}
2808            ]}"#,
2809        )
2810        .unwrap();
2811        assert_eq!(resolved.arcs.len(), 1);
2812        assert_eq!(resolved.arcs_unresolved, 0);
2813
2814        for (what, circle) in [
2815            ("no radius", r#""x":0,"y":0,"start":200,"end":340"#),
2816            (
2817                "unreadable radius",
2818                r#""x":0,"y":0,"r":"2mil","start":200,"end":340"#,
2819            ),
2820            ("zero radius", r#""x":0,"y":0,"r":0,"start":200,"end":340"#),
2821            (
2822                "negative radius",
2823                r#""x":0,"y":0,"r":-2,"start":200,"end":340"#,
2824            ),
2825            (
2826                "unreadable start",
2827                r#""x":0,"y":0,"r":2,"start":"200deg","end":340"#,
2828            ),
2829            (
2830                "unreadable end",
2831                r#""x":0,"y":0,"r":2,"start":200,"end":"340deg""#,
2832            ),
2833            (
2834                "unreadable center x",
2835                r#""x":"left","y":0,"r":2,"start":200,"end":340"#,
2836            ),
2837            (
2838                "unreadable center y",
2839                r#""x":0,"y":"up","r":2,"start":200,"end":340"#,
2840            ),
2841        ] {
2842            let json =
2843                format!(r#"{{"name":"A","unit":"mil","spec":[{{"type":"circle",{circle}}}]}}"#);
2844            let (_, report) = import_ventum_reticle_with_report(&json)
2845                .unwrap_or_else(|e| panic!("{what} must not fail the import: {e:?}"));
2846            assert!(report.arcs.is_empty(), "{what}: no points may be reported");
2847            assert_eq!(
2848                report.arcs_unresolved, 1,
2849                "{what}: must be declared unresolved"
2850            );
2851            assert_eq!(
2852                report.dropped_element_types,
2853                vec![(ARC_TAG.to_string(), 1)],
2854                "{what}: keeps its arc tag — the loss is reported as an arc's"
2855            );
2856        }
2857    }
2858
2859    /// `null` is how JSON writes "no value", and this model reads it as absence everywhere a
2860    /// value is optional — never as a value it could not understand.
2861    ///
2862    /// The consequence worth pinning is the one that looks like an inconsistency until the
2863    /// rule is stated: a `circle` carrying `"start":null` alongside an unreadable `end` is a
2864    /// RING, not an arc with a lost sweep. A sweep needs two angles, `null` supplies neither,
2865    /// and one angle alone describes no sweep however it is spelled.
2866    #[test]
2867    fn null_is_read_as_absence_not_as_a_bad_value() {
2868        for (what, circle) in [
2869            (
2870                "start null, end unreadable",
2871                r#""r":2,"start":null,"end":"340deg""#,
2872            ),
2873            (
2874                "start unreadable, end null",
2875                r#""r":2,"start":"200deg","end":null"#,
2876            ),
2877            ("both null", r#""r":2,"start":null,"end":null"#),
2878            (
2879                "every key null",
2880                r#""x":null,"y":null,"r":null,"start":null,"end":null"#,
2881            ),
2882        ] {
2883            let json =
2884                format!(r#"{{"name":"N","unit":"mil","spec":[{{"type":"circle",{circle}}}]}}"#);
2885            let (_, report) = import_ventum_reticle_with_report(&json).unwrap();
2886            assert_eq!(
2887                report.dropped_element_types,
2888                vec![("circle".to_string(), 1)],
2889                "{what}: a declared-but-null angle is no angle, so this is a ring"
2890            );
2891            assert_eq!(report.arcs_unresolved, 0, "{what}: a ring loses no sweep");
2892        }
2893
2894        // A mark's optional `repeat` takes a `null` too, where serde's `Option` reads it as
2895        // absence. One dot is emitted.
2896        let desc = import_ventum_reticle(
2897            r#"{"name":"N","unit":"mil","spec":[{"type":"dot","x":0,"y":1,"repeat":null}]}"#,
2898        )
2899        .unwrap();
2900        assert_eq!(desc.marks.len(), 1);
2901
2902        // And the half that is NOT true, pinned so the sentence above cannot quietly grow
2903        // into "null is absence everywhere". A reticle-level `#[serde(default)]` field fills
2904        // in a key the document omitted and still refuses a present `null` — 0.32.0's
2905        // behaviour, left alone — as does a defaulted field inside a mark's `repeat`.
2906        for metadata in [
2907            r#""name":null"#,
2908            r#""plane":null"#,
2909            r#""unit":null"#,
2910            r#""ref_magnification":null"#,
2911            r#""spec":null"#,
2912        ] {
2913            // The null key on its own: every other reticle-level field has a default, so
2914            // nothing but the null under test can be what refuses this document.
2915            let json = format!(r#"{{{metadata}}}"#);
2916            assert!(
2917                import_ventum_reticle(&json).is_err(),
2918                "{metadata}: a present null is not an absent key at reticle level\n  {json}"
2919            );
2920        }
2921        assert!(
2922            import_ventum_reticle(
2923                r#"{"unit":"mil","spec":[{"type":"dot","x":0,"y":1,
2924                   "repeat":{"axis":"x","step":1,"n":2,"mirror":null}}]}"#
2925            )
2926            .is_err(),
2927            "a null inside a mark's repeat is not an absent key either"
2928        );
2929    }
2930
2931    /// A mark's own position refuses a present `null`, like any other value it cannot read.
2932    ///
2933    /// This is the one axis [`strictness_is_exactly_what_a_hold_is_built_from`] delegates
2934    /// rather than drives: [`WRONG_TYPED`] deliberately omits `null` and hands the whole null
2935    /// boundary to [`null_is_read_as_absence_not_as_a_bad_value`], which pins a `circle`'s
2936    /// keys, a mark's `repeat`, the reticle-level fields and `repeat.mirror` — and, until
2937    /// this test, never a `dot`/`tick` `x`/`y` or a `text`'s string.
2938    ///
2939    /// What that gap allowed, measured rather than imagined: give `Element::Dot`'s `x` a
2940    /// `deserialize_with` that reads a present `null` as `0.0` while still refusing every
2941    /// other wrong type, and `{"type":"dot","x":null,"y":4}` imports a mark at
2942    /// `right_mil: 0.0` — a hold point at a coordinate nobody wrote — with the whole suite
2943    /// green. That is the failure this module forbids everywhere else, and nothing caught it.
2944    #[test]
2945    fn a_marks_own_position_refuses_a_null_like_any_other_wrong_value() {
2946        for (what, document) in [
2947            (
2948                "dot x",
2949                r#"{"name":"N","unit":"mil","spec":[{"type":"dot","x":null,"y":4}]}"#,
2950            ),
2951            (
2952                "dot y",
2953                r#"{"name":"N","unit":"mil","spec":[{"type":"dot","x":4,"y":null}]}"#,
2954            ),
2955            (
2956                "tick x",
2957                r#"{"name":"N","unit":"mil","spec":[{"type":"tick","x":null,"y":4}]}"#,
2958            ),
2959            (
2960                "tick y",
2961                r#"{"name":"N","unit":"mil","spec":[{"type":"tick","x":4,"y":null}]}"#,
2962            ),
2963            (
2964                "text x",
2965                r#"{"name":"N","unit":"mil","spec":[{"type":"text","x":null,"y":4,"text":"2"}]}"#,
2966            ),
2967            (
2968                "text y",
2969                r#"{"name":"N","unit":"mil","spec":[{"type":"text","x":4,"y":null,"text":"2"}]}"#,
2970            ),
2971            (
2972                "text string",
2973                r#"{"name":"N","unit":"mil","spec":[{"type":"text","x":4,"y":4,"text":null}]}"#,
2974            ),
2975        ] {
2976            assert!(
2977                import_ventum_reticle(document).is_err(),
2978                "{what}: a null where a hold's own coordinate belongs must refuse the \
2979                 document. Reading it as a default would place a mark at a coordinate the \
2980                 document never wrote.\n  {document}"
2981            );
2982        }
2983    }
2984
2985    /// What a `repeat` does to the tally, which is not one answer for every type.
2986    ///
2987    /// A `text` is expanded before it is bound, so each copy that lands on no mark is its own
2988    /// lost label and its own count. A `line` is counted and skipped before expansion begins,
2989    /// so its `repeat` is never read and the element counts once however many copies the
2990    /// document draws. The report's "How the counts are taken" section says exactly this; the
2991    /// two numbers below are the difference it is talking about.
2992    #[test]
2993    fn a_repeat_counts_per_instance_for_text_and_once_for_a_dropped_shape() {
2994        let (_, text_report) = import_ventum_reticle_with_report(
2995            r#"{"name":"R","unit":"mil","spec":[
2996                {"type":"text","x":20,"y":20,"text":"t","repeat":{"axis":"x","step":1,"n":3}}
2997            ]}"#,
2998        )
2999        .unwrap();
3000        assert_eq!(
3001            text_report.dropped_element_types,
3002            vec![("text".to_string(), 3)],
3003            "three copies that bound to nothing are three lost labels"
3004        );
3005
3006        let (_, line_report) = import_ventum_reticle_with_report(
3007            r#"{"name":"R","unit":"mil","spec":[
3008                {"type":"line","x1":0,"y1":0,"x2":1,"y2":1,
3009                 "repeat":{"axis":"x","step":1,"n":3}}
3010            ]}"#,
3011        )
3012        .unwrap();
3013        assert_eq!(
3014            line_report.dropped_element_types,
3015            vec![("line".to_string(), 1)],
3016            "a dropped shape's repeat is never expanded, so the element counts once"
3017        );
3018    }
3019
3020    /// A refusal that is not about the `circle` at all, pinned as such.
3021    ///
3022    /// None of these is this module's to give away, and the three swept here are examples
3023    /// rather than the whole set. A numeric literal outside `f64`'s range and JSON nested
3024    /// past serde_json's recursion limit are refused by the PARSER before any element model
3025    /// is consulted; an element that writes its own `type` twice is refused by serde's tag
3026    /// reader before a variant is even chosen. Nothing in this module's leniency can see any
3027    /// of them, and 0.32.0 refused these three the same way.
3028    ///
3029    /// The evidence is that they fall identically on element types that read no fields
3030    /// whatsoever: if one of these documents ever imports as a `line` but not as a `circle`,
3031    /// the difference IS about the circle and this turns red.
3032    #[test]
3033    fn a_refusal_that_is_not_about_a_key_falls_on_every_element_type_alike() {
3034        let deep = format!("{}1{}", "[".repeat(200), "]".repeat(200));
3035        for value in [String::from("1e400"), deep] {
3036            for element_type in every_element_type() {
3037                let json = one_element_document(element_type, "r", &value);
3038                assert!(
3039                    import_ventum_reticle(&json).is_err(),
3040                    "{element_type} with r={value} must be refused by the parser\n  {json}"
3041                );
3042            }
3043        }
3044
3045        // The element's own tag, written twice. Not a key this module reads, and not a key
3046        // any leniency here could reach: the tag is consumed before the variant exists.
3047        let mut refusals = 0usize;
3048        for element_type in every_element_type() {
3049            let json = format!(
3050                r#"{{"name":"S","unit":"mil","spec":[
3051                    {{"type":"{element_type}","type":"{element_type}","x":0,"y":1}}
3052                ]}}"#
3053            );
3054            assert!(
3055                import_ventum_reticle(&json).is_err(),
3056                "{element_type} written with two `type` keys must be refused\n  {json}"
3057            );
3058            refusals += 1;
3059        }
3060        assert_eq!(refusals, every_element_type().len());
3061    }
3062
3063    #[test]
3064    fn imported_mbr_reticle_hold_solves() {
3065        let json = format!(r#"{{"name":"MBR","plane":"ffp","unit":"mil","spec":{MBR_SPEC}}}"#);
3066        let desc = import_ventum_reticle(&json).unwrap();
3067        // (down 4, right 2) is a real MBR mark, so a matching hold lands on it.
3068        let hold = hold_point_in_reticle(4.0, 2.0, 1.0, &desc).unwrap();
3069        assert!(hold.nearest_mark.is_some());
3070        assert!(
3071            hold.nearest_mark_distance_mil < 0.5,
3072            "hold should sit on/near a real mark (distance {})",
3073            hold.nearest_mark_distance_mil
3074        );
3075        assert!(!hold.off_reticle, "a mid-tree hold is on the reticle");
3076    }
3077}