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}