ballistics_engine/reticle_document_import.rs
1//! Cleanroom importer for the `.reticle` XML document format.
2//!
3//! A `.reticle` file is a third-party **drawing** format for scope reticles: a single
4//! `<reticle>` root carrying an `<elements>` list of shapes (lines, circles, rectangles,
5//! filled paths) plus a `<bdc>` section. This module is the one-way transform from that
6//! authoring format into the engine's own [`ReticleDescription`], so a reticle drawn in
7//! that format can be hold-solved by the existing
8//! [`hold_point_in_reticle`](crate::reticle::hold_point_in_reticle) without any of the
9//! CLI/FFI/WASM surfaces having to learn a second schema. It is a converter, not new
10//! physics — every coordinate is carried straight through, only unit-normalized to
11//! milliradians and sign-corrected for the engine's axis convention.
12//!
13//! It is a sibling of [`crate::reticle_import`] (Bero's "Ventum" JSON), and meets the same
14//! hold-vs-decoration problem from the opposite direction: Ventum is an aiming-mark format
15//! that happens to contain some decoration, while `.reticle` is a picture that happens to
16//! contain some aiming marks.
17//!
18//! Everything here was derived from a sample `.reticle` document and the SVG it renders
19//! to — the observable file structure, and the observable output that pins down what its
20//! coordinates mean. **No third-party implementation of this format was consulted, and
21//! none is vendored.** A file format is not itself copyrightable; this crate stays MIT OR
22//! Apache-2.0. Every fixture in this module's tests and in
23//! `tests/fixtures/reticle_document/` is invented content.
24//!
25//! Like [`crate::reticle_import`], this module is fs-free (it transforms an in-memory
26//! `&str`), carries no feature gate, and compiles for `wasm32-unknown-unknown`.
27//!
28//! # What carries a hold, and what does not
29//!
30//! This is the whole design decision, and the format answers it itself rather than leaving
31//! it to be inferred:
32//!
33//! * **`<bdc>` entries are the holds.** A `<bdc position-x="…" position-y="…">` element is
34//! a declared aiming point. Each one becomes a [`ReticleMark`].
35//! * **Everything under `<elements>` is the picture, and is dropped.** `reticle-line`,
36//! `reticle-circle`, `reticle-rectangle`, `reticle-path` (and its
37//! `reticle-path-move-to` / `reticle-path-line-to` vertices), and any unknown future tag
38//! carry no hold. Their geometry attributes are never read as angles at all — a shape
39//! this module drops cannot fail its import.
40//!
41//! The evidence that this split is the format's own and not our invention: in a real
42//! document the `<bdc>` positions are *duplicates* of shapes already drawn under
43//! `<elements>`. A BDC reticle draws its three holdover marks as three short
44//! `reticle-line` ticks, and then lists those same three coordinates again under `<bdc>`.
45//! The drawing says how it looks; the `<bdc>` section says what it means. Importing the
46//! drawing as well would double every holdover.
47//!
48//! ## The cost of that rule, stated plainly
49//!
50//! A `.reticle` document's windage marks are drawn as `reticle-line` ticks and are *not*
51//! listed under `<bdc>`. They are therefore dropped, and an imported reticle usually has
52//! marks on the elevation axis only. That has a visible consequence downstream: with every
53//! mark at `right_mil == 0`, the windage axis of the mark bounding box is degenerate, so
54//! [`ReticleHold::off_reticle`](crate::reticle::ReticleHold::off_reticle) reads `true` for
55//! *any* nonzero wind hold — see that field's documentation. For a reticle that really does
56//! have windage hashes etched on it, that answer comes from this import, not from the optic.
57//!
58//! Recovering those marks would mean classifying `reticle-line` elements as hash marks by
59//! their geometry (short, axis-perpendicular, off-center) — inventing holds out of
60//! decoration, and doing it with a heuristic that cannot distinguish a windage hash from a
61//! crosshair arm. This module will not guess. Instead the drop is made countable rather
62//! than silent: [`import_reticle_document_with_report`] returns a
63//! [`ReticleImportReport`] tallying every drawing tag it walked past, so a caller can tell
64//! the shooter how many lines and paths this document drew that are not holds, instead of
65//! leaving them to infer it from a suspiciously empty reticle.
66//!
67//! Two related non-inventions, for the reader who wonders:
68//!
69//! * **No synthetic center mark.** The zero *is* the coordinate origin every imported mark
70//! is measured from, and a hold is reported in those same coordinates, so it survives as
71//! the frame rather than as a mark. If a document wants the center to be a selectable
72//! aiming point it can say so with a `<bdc position-x="0…" position-y="0…">` entry.
73//! * **No labels.** A `<bdc>` entry carries no range and no text — only a position and the
74//! `text-offset` / `text-height` hints for where a renderer should draw a label. The range
75//! that belongs to a holdover is a property of the firing solution, not of the document,
76//! so every imported mark has `label: None`.
77//!
78//! # Coordinate and unit conventions
79//!
80//! **The document's `+y` is UP; the engine's is DOWN.** This is the one sign trap of the
81//! format, and it is the opposite of [`crate::reticle_import`], where no flip was needed.
82//! Confirmed from rendered output: an element at `position-y="8moa"` renders *above* the
83//! zero, and a holdover BDC entry sits at a *negative* `position-y`. So
84//!
85//! ```text
86//! right_mil = position-x down_mil = -position-y
87//! ```
88//!
89//! and a holdover's negative `position-y` becomes the positive `down_mil` the engine wants.
90//!
91//! Every value names its own unit inline as a suffix — `"96moa"`, `"-0.55moa"`,
92//! `"1.5mil"`. There is no document-level unit declaration, so each attribute is converted
93//! on its own: `moa` scales by [`MOA_TO_MIL`] (shared with [`crate::reticle_import`], so
94//! the two importers cannot drift apart), `mil` / `mils` / `mrad` pass through. A value
95//! with no suffix, or with a suffix this module does not recognize, is a hard error rather
96//! than a guessed scale — silently mis-scaling a reticle by 3.44x is worse than refusing it.
97//!
98//! ## `size-x` / `size-y` / `zero-x` / `zero-y` are canvas placement, not a mark offset
99//!
100//! The root declares a canvas extent (`size-x`, `size-y`) and the position of the zero
101//! within it (`zero-x`, `zero-y`, measured from the canvas top-left). They exist so a
102//! renderer can map a reticle coordinate onto its output surface:
103//!
104//! ```text
105//! canvas_x = (zero-x + position-x) / size-x * width
106//! canvas_y = (zero-y - position-y) / size-y * height (note the minus: +y is up)
107//! ```
108//!
109//! The origin of the element coordinate system *is* the zero — that is what the attributes
110//! are named after — so element coordinates are already zero-relative and `zero-x`/`zero-y`
111//! do **not** enter this transform. Changing them re-frames the picture on its canvas; it
112//! does not move a hold relative to the point of aim. They are reported (as
113//! [`ReticleImportReport::zero_in_canvas_mil`]) rather than applied, and there is a test
114//! that two documents differing only in their zero attributes import to identical marks.
115//!
116//! # What the format does not carry
117//!
118//! `.reticle` has no focal-plane and no magnification attribute. A document's own name
119//! string may hint at one in prose (`"… (4-12x40, at 12x)"`), but that is text for a human,
120//! not a field, and this module does not mine it.
121//!
122//! An imported description is therefore always [`FocalPlane::First`] with
123//! `reference_magnification = 1.0` — the identity, the only choice that scales nothing.
124//! **If the reticle is actually SFP, the caller must set
125//! [`ReticleDescription::focal_plane`] and
126//! [`ReticleDescription::reference_magnification`] from the optic before solving**, or
127//! every hold will be computed as though the subtensions were magnification-independent.
128//! Both fields are public precisely so that is a one-line fix at the call site.
129//!
130//! # Safety
131//!
132//! [`crate::reticle::MAX_RETICLE_MARKS`] is enforced *while* `<bdc>` entries are collected,
133//! so a hostile document with a million entries returns [`ReticleError::TooManyMarks`]
134//! promptly instead of allocating. Element nesting is bounded by
135//! [`MAX_DOCUMENT_DEPTH`], and the scanner is iterative — no recursion, so a deeply nested
136//! document cannot overflow the stack. This is the same defense as the 0.31.0 reticle
137//! generator size guards and the Ventum importer's repeat-expansion cap.
138
139use std::collections::BTreeMap;
140
141use crate::reticle::{
142 FocalPlane, MarkKind, ReticleDescription, ReticleError, ReticleMark, MAX_RETICLE_MARKS,
143};
144use crate::reticle_import::MOA_TO_MIL;
145
146/// Deepest element nesting a `.reticle` document may use before it is rejected.
147///
148/// Real documents nest three deep (`reticle` > `elements` > `reticle-path` > `elements` >
149/// `reticle-path-line-to` is five). The cap exists so a hostile document cannot grow the
150/// open-element stack without bound; nothing legitimate approaches it.
151pub const MAX_DOCUMENT_DEPTH: usize = 64;
152
153/// The document root.
154const TAG_RETICLE: &str = "reticle";
155/// A structural container for child shapes. Appears both directly under the root and
156/// inside a `reticle-path` (whose vertices are its `<elements>`), so it is treated as
157/// structure wherever it occurs.
158const TAG_ELEMENTS: &str = "elements";
159/// Both the hold-bearing entry and the section that wraps a list of them; the two are told
160/// apart by whether the element carries a position (see [`bdc_mark`]).
161const TAG_BDC: &str = "bdc";
162
163const ATTR_NAME: &str = "name";
164const ATTR_SIZE_X: &str = "size-x";
165const ATTR_SIZE_Y: &str = "size-y";
166const ATTR_ZERO_X: &str = "zero-x";
167const ATTR_ZERO_Y: &str = "zero-y";
168const ATTR_POSITION_X: &str = "position-x";
169const ATTR_POSITION_Y: &str = "position-y";
170
171/// What an import left behind, so the hold/decoration rule is countable rather than
172/// implicit. See the module documentation for why the drawing is dropped.
173#[derive(Debug, Clone, PartialEq, Default)]
174pub struct ReticleImportReport {
175 /// How many drawing tags were walked past — the sum of [`Self::dropped_element_tags`].
176 ///
177 /// This counts *tags*, not shapes: a filled `reticle-path` contributes itself plus one
178 /// per `reticle-path-move-to` / `reticle-path-line-to` vertex, because that is what the
179 /// document literally contains.
180 pub drawing_elements_dropped: usize,
181 /// Per-tag counts of that drawing, sorted by tag name so the report is deterministic.
182 pub dropped_element_tags: Vec<(String, usize)>,
183 /// The document's declared canvas extent as `(size-x, size-y)` in milliradians, when
184 /// the root declares both. Rendering metadata — see the module documentation.
185 pub canvas_mil: Option<(f64, f64)>,
186 /// Where the zero sits inside that canvas, as `(zero-x, zero-y)` in milliradians from
187 /// the canvas top-left, when the root declares both. Rendering metadata: it does NOT
188 /// offset the imported marks.
189 pub zero_in_canvas_mil: Option<(f64, f64)>,
190}
191
192/// Import a `.reticle` XML document into a [`ReticleDescription`].
193///
194/// `xml` is one `.reticle` document (see the module documentation for the grammar). The
195/// returned description is ready for
196/// [`hold_point_in_reticle`](crate::reticle::hold_point_in_reticle): its marks are in
197/// milliradians from the zero, positive `down_mil` below it.
198///
199/// The description is always [`FocalPlane::First`] with `reference_magnification = 1.0`,
200/// because the format carries neither; a caller holding an SFP optic must overwrite both
201/// fields before solving.
202///
203/// This is a pure transform: like [`crate::reticle_import::import_ventum_reticle`] it does
204/// not itself reject an all-decoration document (the resulting description simply carries
205/// no marks, which [`hold_point_in_reticle`](crate::reticle::hold_point_in_reticle) then
206/// reports as [`ReticleError::NoMarks`]). It DOES enforce
207/// [`crate::reticle::MAX_RETICLE_MARKS`] while collecting.
208///
209/// Use [`import_reticle_document_with_report`] when you want to know what was dropped.
210///
211/// # Errors
212///
213/// Returns [`ReticleError::InvalidSpec`] if `xml` is not well-formed, is not rooted at
214/// `<reticle>`, or carries an attribute value this module cannot read as an angle (missing
215/// unit suffix, unrecognized unit, non-numeric, non-finite, non-positive canvas), and
216/// [`ReticleError::TooManyMarks`] if the document declares more `<bdc>` entries than the
217/// mark cap.
218pub fn import_reticle_document(xml: &str) -> Result<ReticleDescription, ReticleError> {
219 import_reticle_document_with_report(xml).map(|(description, _)| description)
220}
221
222/// Import a `.reticle` document, also returning a [`ReticleImportReport`] of what the
223/// hold/decoration rule left behind.
224///
225/// Identical to [`import_reticle_document`] in every other respect; that function is this
226/// one with the report discarded.
227///
228/// # Errors
229///
230/// As [`import_reticle_document`].
231pub fn import_reticle_document_with_report(
232 xml: &str,
233) -> Result<(ReticleDescription, ReticleImportReport), ReticleError> {
234 let mut scanner = XmlScanner::new(xml);
235
236 // Names of the currently open elements, innermost last. A Vec rather than recursion:
237 // depth is data here, and a document must not be able to nest us into a stack overflow.
238 let mut open: Vec<&str> = Vec::new();
239 let mut root_seen = false;
240
241 let mut name = String::new();
242 let (mut size_x, mut size_y) = (None, None);
243 let (mut zero_x, mut zero_y) = (None, None);
244 let mut marks: Vec<ReticleMark> = Vec::new();
245 // Keyed by a borrowed slice of the input, so a document with many distinct tag names
246 // costs pointers rather than copies; owned only when the report is built.
247 let mut dropped: BTreeMap<&str, usize> = BTreeMap::new();
248
249 while let Some(event) = scanner.next_event()? {
250 match event {
251 XmlEvent::Start {
252 tag,
253 attributes,
254 self_closing,
255 } => {
256 if open.is_empty() {
257 // Top level: this must be the one and only root, and it must be a
258 // `.reticle` document rather than some other XML that happens to parse.
259 if root_seen {
260 return Err(invalid(
261 "a .reticle document must have exactly one root element".to_string(),
262 ));
263 }
264 if tag != TAG_RETICLE {
265 return Err(invalid(format!(
266 "expected a <{TAG_RETICLE}> root element, found <{tag}>"
267 )));
268 }
269 root_seen = true;
270
271 if let Some(raw) = attribute(&attributes, ATTR_NAME) {
272 name = decode_entities(raw);
273 }
274 size_x = parse_canvas_extent(&attributes, ATTR_SIZE_X)?;
275 size_y = parse_canvas_extent(&attributes, ATTR_SIZE_Y)?;
276 zero_x = parse_optional_angle_mil(&attributes, ATTR_ZERO_X)?;
277 zero_y = parse_optional_angle_mil(&attributes, ATTR_ZERO_Y)?;
278 } else {
279 match tag {
280 // Pure structure: neither a hold nor a drawn shape.
281 TAG_ELEMENTS => {}
282 // The hold-bearing section. A `<bdc>` that carries a position is an
283 // entry; one that carries none is the wrapper around entries, and
284 // contributes nothing itself.
285 TAG_BDC => {
286 if let Some(mark) = bdc_mark(&attributes)? {
287 push_capped(&mut marks, mark)?;
288 }
289 }
290 // Everything else is the picture. Its geometry attributes are never
291 // parsed — only tallied, so the drop is visible in the report.
292 drawing => *dropped.entry(drawing).or_insert(0) += 1,
293 }
294 }
295
296 if !self_closing {
297 if open.len() >= MAX_DOCUMENT_DEPTH {
298 return Err(invalid(format!(
299 "element nesting deeper than {MAX_DOCUMENT_DEPTH} at <{tag}>"
300 )));
301 }
302 open.push(tag);
303 }
304 }
305 XmlEvent::End { tag } => match open.pop() {
306 Some(expected) if expected == tag => {}
307 Some(expected) => {
308 return Err(invalid(format!(
309 "</{tag}> closes <{expected}>, which is still open"
310 )))
311 }
312 None => return Err(invalid(format!("</{tag}> has no matching open element"))),
313 },
314 }
315 }
316
317 if let Some(unclosed) = open.last() {
318 return Err(invalid(format!("<{unclosed}> is never closed")));
319 }
320 if !root_seen {
321 return Err(invalid(format!(
322 "the document has no <{TAG_RETICLE}> root element"
323 )));
324 }
325
326 let report = ReticleImportReport {
327 drawing_elements_dropped: dropped.values().sum(),
328 dropped_element_tags: dropped
329 .into_iter()
330 .map(|(tag, count)| (tag.to_string(), count))
331 .collect(),
332 canvas_mil: size_x.zip(size_y),
333 zero_in_canvas_mil: zero_x.zip(zero_y),
334 };
335
336 let description = ReticleDescription {
337 name,
338 // The format declares no focal plane, so the import applies the identity: FFP
339 // subtensions do not scale with magnification. See the module documentation.
340 focal_plane: FocalPlane::First,
341 reference_magnification: 1.0,
342 marks,
343 };
344
345 Ok((description, report))
346}
347
348/// Turn one `<bdc>` element into a mark, or `None` when it is the section wrapper rather
349/// than an entry.
350///
351/// An entry is any `<bdc>` carrying at least one of `position-x` / `position-y`; the
352/// wrapper carries neither. A missing axis defaults to `0` — an entry that names only its
353/// `position-y` sits on the vertical stadia, which is where a holdover ladder lives.
354fn bdc_mark(attributes: &[(&str, &str)]) -> Result<Option<ReticleMark>, ReticleError> {
355 let raw_x = attribute(attributes, ATTR_POSITION_X);
356 let raw_y = attribute(attributes, ATTR_POSITION_Y);
357 if raw_x.is_none() && raw_y.is_none() {
358 return Ok(None);
359 }
360
361 let right_mil = match raw_x {
362 Some(raw) => parse_angle_mil(raw, ATTR_POSITION_X)?,
363 None => 0.0,
364 };
365 let up_mil = match raw_y {
366 Some(raw) => parse_angle_mil(raw, ATTR_POSITION_Y)?,
367 None => 0.0,
368 };
369
370 // THE sign flip: the document measures +y UP from the zero, the engine measures
371 // +down_mil DOWN from it. A holdover's negative position-y is a positive down_mil.
372 // `text-offset` / `text-height` are label-rendering hints and are dropped; a `<bdc>`
373 // entry carries no range text to become a mark label.
374 Ok(Some(ReticleMark::new(
375 -up_mil,
376 right_mil,
377 // The format gives a bdc point no shape (the shape lives in the drawing), so this
378 // is the conventional drawing of a holdover point on a stadia line. `MarkKind` is
379 // descriptive only and has no effect on the hold math.
380 MarkKind::Hash,
381 )))
382}
383
384/// Push one mark, rejecting the document the instant the running count would exceed
385/// [`MAX_RETICLE_MARKS`]. Checking before every push bounds the allocation at the cap
386/// regardless of how many `<bdc>` entries the document declares.
387fn push_capped(marks: &mut Vec<ReticleMark>, mark: ReticleMark) -> Result<(), ReticleError> {
388 if marks.len() >= MAX_RETICLE_MARKS {
389 return Err(ReticleError::TooManyMarks {
390 count: marks.len() + 1,
391 max: MAX_RETICLE_MARKS,
392 });
393 }
394 marks.push(mark);
395 Ok(())
396}
397
398/// First value for `wanted`, or `None`. XML forbids duplicate attribute names; if a
399/// document has them anyway the first wins rather than the parse failing.
400fn attribute<'a>(attributes: &[(&'a str, &'a str)], wanted: &str) -> Option<&'a str> {
401 attributes
402 .iter()
403 .find(|(key, _)| *key == wanted)
404 .map(|(_, value)| *value)
405}
406
407/// Parse an optional angle-valued root attribute.
408fn parse_optional_angle_mil(
409 attributes: &[(&str, &str)],
410 wanted: &'static str,
411) -> Result<Option<f64>, ReticleError> {
412 match attribute(attributes, wanted) {
413 Some(raw) => parse_angle_mil(raw, wanted).map(Some),
414 None => Ok(None),
415 }
416}
417
418/// Parse a canvas extent (`size-x` / `size-y`), which must be strictly positive.
419///
420/// The extent never reaches a mark, so this check is not protecting the math — it is
421/// protecting the reading. A canvas of zero or negative width means the document is not
422/// shaped the way this module believes it is, and importing marks out of a file we have
423/// evidently misread is worse than refusing it.
424fn parse_canvas_extent(
425 attributes: &[(&str, &str)],
426 wanted: &'static str,
427) -> Result<Option<f64>, ReticleError> {
428 let Some(value) = parse_optional_angle_mil(attributes, wanted)? else {
429 return Ok(None);
430 };
431 if value <= 0.0 {
432 return Err(invalid(format!(
433 "{wanted} must be greater than zero (got {value} mil)"
434 )));
435 }
436 Ok(Some(value))
437}
438
439/// Convert one unit-suffixed attribute value to milliradians.
440///
441/// `.reticle` writes the unit inline on every value (`"96moa"`, `"-0.55moa"`, `"1.5mil"`),
442/// so each attribute is converted on its own terms; there is no document-level unit to
443/// fall back on. A value with no suffix or an unrecognized one is refused rather than
444/// assigned a guessed scale.
445fn parse_angle_mil(raw: &str, attribute: &str) -> Result<f64, ReticleError> {
446 let text = raw.trim();
447
448 // The unit is the trailing run of ASCII letters. Splitting from the right (rather than
449 // scanning for the first letter) keeps scientific notation intact: "1e5moa" splits into
450 // "1e5" and "moa", because the digit stops the run.
451 let suffix_len = text
452 .chars()
453 .rev()
454 .take_while(|c| c.is_ascii_alphabetic())
455 .count();
456 let (number, unit) = text.split_at(text.len() - suffix_len);
457
458 let scale = if unit.eq_ignore_ascii_case("moa") {
459 MOA_TO_MIL
460 } else if unit.eq_ignore_ascii_case("mil")
461 || unit.eq_ignore_ascii_case("mils")
462 || unit.eq_ignore_ascii_case("mrad")
463 {
464 1.0
465 } else if unit.is_empty() {
466 return Err(invalid(format!(
467 "{attribute}=\"{raw}\" has no unit suffix; every .reticle value names its own \
468 unit (e.g. \"1.5moa\")"
469 )));
470 } else {
471 return Err(invalid(format!(
472 "{attribute}=\"{raw}\" uses the unrecognized unit \"{unit}\"; this importer \
473 reads moa, mil and mrad"
474 )));
475 };
476
477 let value: f64 = number.trim().parse().map_err(|_| {
478 invalid(format!(
479 "{attribute}=\"{raw}\" is not a number followed by a unit"
480 ))
481 })?;
482 if !value.is_finite() {
483 return Err(invalid(format!("{attribute}=\"{raw}\" is not finite")));
484 }
485
486 Ok(value * scale)
487}
488
489fn invalid(reason: String) -> ReticleError {
490 ReticleError::InvalidSpec(reason)
491}
492
493/// Expand the five predefined XML entities and numeric character references.
494///
495/// Only ever applied to `name`, the one attribute that becomes a human-readable string.
496/// An unrecognized entity is left verbatim rather than failing the import — a mangled
497/// display name is not worth refusing a reticle over.
498fn decode_entities(raw: &str) -> String {
499 if !raw.contains('&') {
500 return raw.to_string();
501 }
502
503 let mut out = String::with_capacity(raw.len());
504 let mut rest = raw;
505 while let Some(start) = rest.find('&') {
506 out.push_str(&rest[..start]);
507 let tail = &rest[start..];
508 let Some(end) = tail.find(';') else {
509 // No terminator at all: the remainder is literal text.
510 out.push_str(tail);
511 return out;
512 };
513 let entity = &tail[1..end];
514 match entity {
515 "amp" => out.push('&'),
516 "lt" => out.push('<'),
517 "gt" => out.push('>'),
518 "quot" => out.push('"'),
519 "apos" => out.push('\''),
520 _ => {
521 let decoded = entity.strip_prefix('#').and_then(|digits| {
522 let code = match digits.strip_prefix(['x', 'X']) {
523 Some(hex) => u32::from_str_radix(hex, 16).ok()?,
524 None => digits.parse::<u32>().ok()?,
525 };
526 char::from_u32(code)
527 });
528 match decoded {
529 Some(c) => out.push(c),
530 None => out.push_str(&tail[..=end]),
531 }
532 }
533 }
534 rest = &tail[end + 1..];
535 }
536 out.push_str(rest);
537 out
538}
539
540// ---------------------------------------------------------------------------------------
541// Minimal XML scanner.
542//
543// Hand-rolled rather than pulled from a crate, for the same reason `profile_import` hand-
544// rolls its protobuf wire decoder: this crate ships to thirteen platforms plus wasm32, and
545// a whole XML library is a large dependency to carry for one importer that needs elements
546// and attributes and nothing else. Text nodes, CDATA, namespaces, DTDs and entity
547// declarations are all skipped — a `.reticle` document puts every value in an attribute,
548// so no element content is ever read.
549//
550// It is a scanner, not a validator: it enforces the well-formedness a misread would hide
551// (balanced tags, quoted attribute values, one root) and ignores the rest.
552// ---------------------------------------------------------------------------------------
553
554/// One scanned markup event. Comments, processing instructions, doctypes, CDATA and text
555/// never surface.
556#[derive(Debug)]
557enum XmlEvent<'a> {
558 Start {
559 tag: &'a str,
560 attributes: Vec<(&'a str, &'a str)>,
561 self_closing: bool,
562 },
563 End {
564 tag: &'a str,
565 },
566}
567
568struct XmlScanner<'a> {
569 src: &'a str,
570 pos: usize,
571}
572
573impl<'a> XmlScanner<'a> {
574 fn new(src: &'a str) -> Self {
575 Self { src, pos: 0 }
576 }
577
578 fn bytes(&self) -> &'a [u8] {
579 self.src.as_bytes()
580 }
581
582 fn rest(&self) -> &'a str {
583 &self.src[self.pos..]
584 }
585
586 /// Advance past `open`-prefixed markup terminated by `close`, or fail naming it.
587 fn skip_delimited(&mut self, open: &str, close: &str, what: &str) -> Result<(), ReticleError> {
588 let after_open = self.pos + open.len();
589 match self.src[after_open..].find(close) {
590 Some(offset) => {
591 self.pos = after_open + offset + close.len();
592 Ok(())
593 }
594 None => Err(invalid(format!(
595 "unterminated {what} at byte {} (expected {close:?})",
596 self.pos
597 ))),
598 }
599 }
600
601 /// The next start or end tag, or `None` at end of input.
602 fn next_event(&mut self) -> Result<Option<XmlEvent<'a>>, ReticleError> {
603 loop {
604 // Everything between tags is text content, which this format never uses.
605 match self.rest().find('<') {
606 Some(offset) => self.pos += offset,
607 None => {
608 self.pos = self.src.len();
609 return Ok(None);
610 }
611 }
612
613 let rest = self.rest();
614 if rest.starts_with("<!--") {
615 self.skip_delimited("<!--", "-->", "comment")?;
616 } else if rest.starts_with("<![CDATA[") {
617 self.skip_delimited("<![CDATA[", "]]>", "CDATA section")?;
618 } else if rest.starts_with("<?") {
619 self.skip_delimited("<?", "?>", "processing instruction")?;
620 } else if rest.starts_with("<!") {
621 // DOCTYPE and friends. A document type declaration with an internal subset
622 // would end at the wrong '>', which is precisely why nothing here reads
623 // element content: the worst case is a spurious parse error, never a
624 // silently different reticle.
625 self.skip_delimited("<!", ">", "declaration")?;
626 } else if rest.starts_with("</") {
627 return self.read_end_tag().map(Some);
628 } else {
629 return self.read_start_tag().map(Some);
630 }
631 }
632 }
633
634 fn read_end_tag(&mut self) -> Result<XmlEvent<'a>, ReticleError> {
635 self.pos += 2; // past "</"
636 let start = self.pos;
637 let Some(tag) = self.read_name() else {
638 return Err(invalid(format!(
639 "an end tag has an empty name at byte {start}"
640 )));
641 };
642 self.skip_whitespace();
643 if self.bytes().get(self.pos) != Some(&b'>') {
644 return Err(invalid(format!(
645 "</{tag}> is malformed at byte {}",
646 self.pos
647 )));
648 }
649 self.pos += 1;
650 Ok(XmlEvent::End { tag })
651 }
652
653 fn read_start_tag(&mut self) -> Result<XmlEvent<'a>, ReticleError> {
654 self.pos += 1; // past '<'
655 let start = self.pos;
656 let Some(tag) = self.read_name() else {
657 return Err(invalid(format!(
658 "a start tag has an empty name at byte {start}"
659 )));
660 };
661 let mut attributes: Vec<(&'a str, &'a str)> = Vec::new();
662
663 loop {
664 self.skip_whitespace();
665 let rest = self.rest();
666 if rest.starts_with("/>") {
667 self.pos += 2;
668 return Ok(XmlEvent::Start {
669 tag,
670 attributes,
671 self_closing: true,
672 });
673 }
674 if rest.starts_with('>') {
675 self.pos += 1;
676 return Ok(XmlEvent::Start {
677 tag,
678 attributes,
679 self_closing: false,
680 });
681 }
682 if rest.is_empty() {
683 return Err(invalid(format!("<{tag}> is never terminated")));
684 }
685
686 let Some(key) = self.read_name() else {
687 return Err(invalid(format!(
688 "<{tag}> has a malformed attribute at byte {}",
689 self.pos
690 )));
691 };
692 self.skip_whitespace();
693 if self.bytes().get(self.pos) != Some(&b'=') {
694 return Err(invalid(format!(
695 "attribute {key:?} of <{tag}> has no value"
696 )));
697 }
698 self.pos += 1;
699 self.skip_whitespace();
700
701 let quote = match self.bytes().get(self.pos) {
702 Some(&q @ (b'"' | b'\'')) => q,
703 _ => {
704 return Err(invalid(format!(
705 "attribute {key:?} of <{tag}> must be quoted"
706 )))
707 }
708 };
709 self.pos += 1;
710 let start = self.pos;
711 let Some(offset) = self.rest().find(quote as char) else {
712 return Err(invalid(format!(
713 "attribute {key:?} of <{tag}> has an unterminated value"
714 )));
715 };
716 self.pos = start + offset;
717 attributes.push((key, &self.src[start..self.pos]));
718 self.pos += 1; // past the closing quote
719 }
720 }
721
722 /// Read an element or attribute name: everything up to whitespace or one of `/>=`.
723 /// `None` when there is no name there at all, which every caller reports as malformed.
724 fn read_name(&mut self) -> Option<&'a str> {
725 let start = self.pos;
726 let bytes = self.bytes();
727 while let Some(&byte) = bytes.get(self.pos) {
728 if byte.is_ascii_whitespace() || matches!(byte, b'/' | b'>' | b'=') {
729 break;
730 }
731 self.pos += 1;
732 }
733 if self.pos == start {
734 return None;
735 }
736 Some(&self.src[start..self.pos])
737 }
738
739 fn skip_whitespace(&mut self) {
740 let bytes = self.bytes();
741 while matches!(bytes.get(self.pos), Some(byte) if byte.is_ascii_whitespace()) {
742 self.pos += 1;
743 }
744 }
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750 use crate::reticle::hold_point_in_reticle;
751
752 /// A miniature but complete document in the shape a real one takes: a filled background
753 /// rectangle, a field-of-view circle, crosshair arms, one windage hash, a filled post
754 /// path, and a `<bdc>` section listing three holdovers that the drawing also draws.
755 /// Invented content — the geometry is not any product's reticle.
756 const LADDER_MOA: &str = r#"<?xml version="1.0" encoding="utf-8"?>
757<reticle name="Test Ladder MOA" size-x="40moa" size-y="40moa" zero-x="20moa" zero-y="20moa">
758 <elements>
759 <reticle-rectangle position-x="-20moa" position-y="20moa" size-x="40moa" size-y="40moa" fill="true" color="black" />
760 <reticle-circle center-x="0moa" center-y="0moa" radius="19moa" fill="true" line-width="0moa" color="white" />
761 <!-- crosshair arms -->
762 <reticle-line start-x="0moa" start-y="0moa" end-x="0moa" end-y="6moa" line-width="0.2moa" line-color="black" />
763 <reticle-line start-x="0moa" start-y="0moa" end-x="0moa" end-y="-9moa" line-width="0.2moa" line-color="black" />
764 <reticle-line start-x="-3moa" start-y="-0.5moa" end-x="-3moa" end-y="0.5moa" line-width="0.2moa" line-color="black" />
765 <!-- the holdover ticks the bdc section names again -->
766 <reticle-line start-x="-0.5moa" start-y="-2moa" end-x="0.5moa" end-y="-2moa" line-width="0.2moa" line-color="black" />
767 <reticle-line start-x="-0.5moa" start-y="-5moa" end-x="0.5moa" end-y="-5moa" line-width="0.2moa" line-color="black" />
768 <reticle-line start-x="-0.5moa" start-y="-8moa" end-x="0.5moa" end-y="-8moa" line-width="0.2moa" line-color="black" />
769 <reticle-path fill="true" color="black">
770 <elements>
771 <reticle-path-move-to position-x="-0.3moa" position-y="19moa" />
772 <reticle-path-line-to position-x="0.3moa" position-y="19moa" />
773 <reticle-path-line-to position-x="0.3moa" position-y="6moa" />
774 <reticle-path-line-to position-x="-0.3moa" position-y="6moa" />
775 </elements>
776 </reticle-path>
777 </elements>
778 <bdc>
779 <bdc position-x="0moa" position-y="-2moa" text-offset="1moa" text-height="0.8moa" />
780 <bdc position-x="0moa" position-y="-5moa" text-offset="1moa" text-height="0.8moa" />
781 <bdc position-x="0moa" position-y="-8moa" text-offset="1moa" text-height="0.8moa" />
782 </bdc>
783</reticle>"#;
784
785 /// The imported `down_mil` values, sorted.
786 fn downs(description: &ReticleDescription) -> Vec<f64> {
787 let mut downs: Vec<f64> = description.marks.iter().map(|m| m.down_mil).collect();
788 downs.sort_by(|a, b| a.partial_cmp(b).unwrap());
789 downs
790 }
791
792 #[test]
793 fn bdc_entries_are_the_only_holds_and_the_drawing_is_dropped() {
794 let (description, report) = import_reticle_document_with_report(LADDER_MOA).unwrap();
795
796 assert_eq!(description.name, "Test Ladder MOA");
797 assert_eq!(
798 description.marks.len(),
799 3,
800 "only the three <bdc> entries carry holds; the drawing draws six lines, a \
801 circle, a rectangle and a path and none of them is a mark"
802 );
803 assert!(
804 description.marks.iter().all(|m| m.kind == MarkKind::Hash),
805 "a bdc point has no declared shape; every imported mark is a hash"
806 );
807 assert!(
808 description.marks.iter().all(|m| m.label.is_none()),
809 "a bdc entry carries no range text, so no mark is labeled"
810 );
811
812 // The three holdover ticks are drawn AND listed; importing both would double them.
813 assert_eq!(
814 report
815 .dropped_element_tags
816 .iter()
817 .find(|(tag, _)| tag == "reticle-line")
818 .map(|(_, count)| *count),
819 Some(6),
820 "the three drawn holdover ticks are dropped as drawing, not imported twice"
821 );
822 assert_eq!(
823 report.dropped_element_tags,
824 vec![
825 ("reticle-circle".to_string(), 1),
826 ("reticle-line".to_string(), 6),
827 ("reticle-path".to_string(), 1),
828 ("reticle-path-line-to".to_string(), 3),
829 ("reticle-path-move-to".to_string(), 1),
830 ("reticle-rectangle".to_string(), 1),
831 ],
832 "the report tallies every drawing tag it walked past, sorted by tag"
833 );
834 assert_eq!(report.drawing_elements_dropped, 13);
835 }
836
837 #[test]
838 fn positive_y_in_the_document_is_down_negated_in_the_engine() {
839 let description = import_reticle_document(LADDER_MOA).unwrap();
840 // -2/-5/-8 MOA in the document (below the zero, +y up) become POSITIVE down_mil.
841 let expected: Vec<f64> = vec![2.0, 5.0, 8.0]
842 .into_iter()
843 .map(|moa| moa * MOA_TO_MIL)
844 .collect();
845 for (got, want) in downs(&description).iter().zip(expected.iter()) {
846 assert!(
847 (got - want).abs() < 1e-12,
848 "holdover {got} mil should be {want} mil below the zero"
849 );
850 }
851 assert!(
852 description.marks.iter().all(|m| m.down_mil > 0.0),
853 "a holdover is BELOW the zero after the sign flip"
854 );
855 }
856
857 #[test]
858 fn a_mark_above_the_zero_imports_as_negative_down() {
859 // A stadia mark above the zero (a positive position-y) is a negative down_mil.
860 let description = import_reticle_document(
861 r#"<reticle name="Up" size-x="10mil" size-y="10mil" zero-x="5mil" zero-y="5mil">
862 <bdc><bdc position-x="0mil" position-y="3mil" /></bdc>
863 </reticle>"#,
864 )
865 .unwrap();
866 assert_eq!(description.marks.len(), 1);
867 assert_eq!(description.marks[0].down_mil, -3.0);
868 }
869
870 #[test]
871 fn each_value_converts_on_its_own_unit_suffix() {
872 // No document-level unit: mixed suffixes in one document all normalize to mil.
873 let description = import_reticle_document(
874 r#"<reticle name="Mixed" size-x="40moa" size-y="10mrad" zero-x="20moa" zero-y="5mrad">
875 <bdc>
876 <bdc position-x="0mil" position-y="-1.5mil" />
877 <bdc position-x="0mrad" position-y="-3mrad" />
878 <bdc position-x="0moa" position-y="-2MOA" />
879 </bdc>
880 </reticle>"#,
881 )
882 .unwrap();
883 // Document order, not sorted: each entry is converted on its own suffix.
884 let got: Vec<f64> = description.marks.iter().map(|m| m.down_mil).collect();
885 assert_eq!(got[0], 1.5, "mil passes through");
886 assert_eq!(got[1], 3.0, "mrad passes through");
887 assert!(
888 (got[2] - 2.0 * MOA_TO_MIL).abs() < 1e-12,
889 "MOA is case-insensitive and scales by MOA_TO_MIL (got {})",
890 got[2]
891 );
892 }
893
894 #[test]
895 fn a_value_this_module_cannot_scale_is_refused_rather_than_guessed() {
896 for (xml, why) in [
897 (
898 r#"<reticle><bdc><bdc position-y="-2" /></bdc></reticle>"#,
899 "no unit suffix",
900 ),
901 (
902 r#"<reticle><bdc><bdc position-y="-2cm100m" /></bdc></reticle>"#,
903 "unrecognized unit",
904 ),
905 (
906 r#"<reticle><bdc><bdc position-y="twomoa" /></bdc></reticle>"#,
907 "not a number",
908 ),
909 (
910 r#"<reticle><bdc><bdc position-y="1e400moa" /></bdc></reticle>"#,
911 "overflows to infinity",
912 ),
913 ] {
914 assert!(
915 matches!(
916 import_reticle_document(xml),
917 Err(ReticleError::InvalidSpec(_))
918 ),
919 "{why} must be an InvalidSpec, not a guessed scale"
920 );
921 }
922 }
923
924 #[test]
925 fn a_non_positive_canvas_is_refused() {
926 assert!(matches!(
927 import_reticle_document(r#"<reticle size-x="0moa" size-y="10moa"></reticle>"#),
928 Err(ReticleError::InvalidSpec(_))
929 ));
930 assert!(matches!(
931 import_reticle_document(r#"<reticle size-x="-4moa" size-y="10moa"></reticle>"#),
932 Err(ReticleError::InvalidSpec(_))
933 ));
934 }
935
936 #[test]
937 fn the_zero_attributes_place_the_canvas_and_never_move_a_mark() {
938 // Two documents identical but for zero-x/zero-y: the zero is the origin of the
939 // element coordinate system, so re-framing the canvas cannot move a hold.
940 let centered = import_reticle_document(
941 r#"<reticle name="Z" size-x="40moa" size-y="40moa" zero-x="20moa" zero-y="20moa">
942 <bdc><bdc position-x="1moa" position-y="-5moa" /></bdc>
943 </reticle>"#,
944 )
945 .unwrap();
946 let offset = import_reticle_document(
947 r#"<reticle name="Z" size-x="40moa" size-y="40moa" zero-x="12moa" zero-y="31moa">
948 <bdc><bdc position-x="1moa" position-y="-5moa" /></bdc>
949 </reticle>"#,
950 )
951 .unwrap();
952 assert_eq!(
953 centered, offset,
954 "zero-x/zero-y are canvas placement; they must not offset the marks"
955 );
956 }
957
958 #[test]
959 fn the_canvas_and_zero_are_reported_in_milliradians() {
960 let (_, report) = import_reticle_document_with_report(LADDER_MOA).unwrap();
961 let (size_x, size_y) = report.canvas_mil.expect("canvas declared");
962 let (zero_x, zero_y) = report.zero_in_canvas_mil.expect("zero declared");
963 assert!((size_x - 40.0 * MOA_TO_MIL).abs() < 1e-12);
964 assert!((size_y - 40.0 * MOA_TO_MIL).abs() < 1e-12);
965 assert!((zero_x - 20.0 * MOA_TO_MIL).abs() < 1e-12);
966 assert!((zero_y - 20.0 * MOA_TO_MIL).abs() < 1e-12);
967
968 // Undeclared canvas metadata is absent, not defaulted.
969 let (_, bare) = import_reticle_document_with_report(
970 r#"<reticle name="Bare"><bdc><bdc position-y="-1mil" /></bdc></reticle>"#,
971 )
972 .unwrap();
973 assert_eq!(bare.canvas_mil, None);
974 assert_eq!(bare.zero_in_canvas_mil, None);
975 }
976
977 #[test]
978 fn a_bdc_wrapper_is_not_itself_a_mark() {
979 // The section wrapper carries no position; only its children are entries. An entry
980 // naming one axis sits at 0 on the other.
981 let description = import_reticle_document(
982 r#"<reticle name="W" size-x="10mil" size-y="10mil">
983 <bdc>
984 <bdc position-y="-1mil" />
985 <bdc position-x="2mil" />
986 </bdc>
987 </reticle>"#,
988 )
989 .unwrap();
990 assert_eq!(description.marks.len(), 2, "the wrapper is not a mark");
991 assert_eq!(description.marks[0].down_mil, 1.0);
992 assert_eq!(description.marks[0].right_mil, 0.0);
993 assert_eq!(description.marks[1].down_mil, 0.0);
994 assert_eq!(description.marks[1].right_mil, 2.0);
995
996 // An empty section contributes nothing at all.
997 let empty = import_reticle_document(r#"<reticle name="E"><bdc /></reticle>"#).unwrap();
998 assert!(empty.marks.is_empty());
999 }
1000
1001 #[test]
1002 fn an_all_decoration_document_imports_with_no_marks() {
1003 // A pure transform: a document that draws a reticle but declares no bdc entry is
1004 // not an error here, it is simply a description with nothing to hold on. The
1005 // rejection belongs to the solver.
1006 let description = import_reticle_document(
1007 r#"<reticle name="Picture" size-x="20moa" size-y="20moa">
1008 <elements>
1009 <reticle-line start-x="-8moa" start-y="0moa" end-x="8moa" end-y="0moa" line-width="0.2moa" />
1010 <reticle-circle center-x="0moa" center-y="0moa" radius="2moa" />
1011 <future-shape whatever="7pizzas" />
1012 </elements>
1013 </reticle>"#,
1014 )
1015 .unwrap();
1016 assert!(description.marks.is_empty());
1017 assert_eq!(description.validate(), Err(ReticleError::NoMarks));
1018 assert!(
1019 matches!(
1020 hold_point_in_reticle(1.0, 0.0, 1.0, &description),
1021 Err(ReticleError::NoMarks)
1022 ),
1023 "an all-decoration reticle has nothing to hold on"
1024 );
1025 }
1026
1027 #[test]
1028 fn dropped_geometry_is_never_read_as_an_angle() {
1029 // Decoration geometry is tallied, not read: an unreadable unit inside a shape we
1030 // drop must not fail the import, exactly as the Ventum importer ignores the fields
1031 // of its decoration variants.
1032 let (description, report) = import_reticle_document_with_report(
1033 r#"<reticle name="Odd" size-x="20moa" size-y="20moa">
1034 <elements>
1035 <reticle-line start-x="nonsense" end-y="12furlongs" />
1036 </elements>
1037 <bdc><bdc position-y="-1moa" /></bdc>
1038 </reticle>"#,
1039 )
1040 .unwrap();
1041 assert_eq!(description.marks.len(), 1);
1042 assert_eq!(report.drawing_elements_dropped, 1);
1043 }
1044
1045 #[test]
1046 fn the_import_is_always_ffp_identity_because_the_format_says_nothing() {
1047 // The name may talk about magnification in prose; the importer does not mine it.
1048 let description = import_reticle_document(
1049 r#"<reticle name="Scope 4-12x40, at 12x" size-x="20moa" size-y="20moa">
1050 <bdc><bdc position-y="-2moa" /></bdc>
1051 </reticle>"#,
1052 )
1053 .unwrap();
1054 assert_eq!(description.focal_plane, FocalPlane::First);
1055 assert_eq!(description.reference_magnification, 1.0);
1056 assert_eq!(
1057 description.name, "Scope 4-12x40, at 12x",
1058 "the magnification hint stays prose in the name"
1059 );
1060 }
1061
1062 #[test]
1063 fn malformed_documents_report_invalid_spec() {
1064 for (xml, why) in [
1065 ("<reticle>", "unclosed root"),
1066 ("<reticle><bdc></reticle>", "mismatched close tag"),
1067 ("</reticle>", "close with nothing open"),
1068 ("", "no root at all"),
1069 (" \n ", "whitespace only"),
1070 ("<scope name=\"x\" />", "root is not <reticle>"),
1071 ("<reticle /><reticle />", "two roots"),
1072 (r#"<reticle name=x />"#, "unquoted attribute value"),
1073 (r#"<reticle name />"#, "attribute with no value"),
1074 (r#"<reticle name="unterminated />"#, "unterminated value"),
1075 ("<reticle", "unterminated start tag"),
1076 ("<!-- never ends", "unterminated comment"),
1077 ("<>", "empty tag name"),
1078 ] {
1079 assert!(
1080 matches!(
1081 import_reticle_document(xml),
1082 Err(ReticleError::InvalidSpec(_))
1083 ),
1084 "{why} must be reported as an InvalidSpec, not accepted"
1085 );
1086 }
1087 }
1088
1089 #[test]
1090 fn prologue_comments_and_cdata_are_skipped() {
1091 let description = import_reticle_document(
1092 r#"<?xml version="1.0"?>
1093 <!DOCTYPE reticle>
1094 <!-- a comment mentioning <reticle-line /> and </reticle> -->
1095 <reticle name="Skip" size-x="10mil" size-y="10mil">
1096 <![CDATA[ <bdc position-y="-99mil" /> ]]>
1097 <bdc><bdc position-y="-1mil" /></bdc>
1098 </reticle>"#,
1099 )
1100 .unwrap();
1101 assert_eq!(
1102 description.marks.len(),
1103 1,
1104 "markup inside a comment or CDATA is text, not an element"
1105 );
1106 assert_eq!(description.marks[0].down_mil, 1.0);
1107 }
1108
1109 #[test]
1110 fn too_many_bdc_entries_are_capped_during_collection() {
1111 let mut xml = String::from(r#"<reticle name="Flood"><bdc>"#);
1112 for _ in 0..(MAX_RETICLE_MARKS + 10) {
1113 xml.push_str(r#"<bdc position-y="-1mil" />"#);
1114 }
1115 xml.push_str("</bdc></reticle>");
1116 assert!(matches!(
1117 import_reticle_document(&xml),
1118 Err(ReticleError::TooManyMarks { max, .. }) if max == MAX_RETICLE_MARKS
1119 ));
1120 }
1121
1122 #[test]
1123 fn nesting_deeper_than_the_cap_is_refused_without_recursing() {
1124 let mut xml = String::from("<reticle>");
1125 for _ in 0..(MAX_DOCUMENT_DEPTH + 5) {
1126 xml.push_str("<elements>");
1127 }
1128 assert!(matches!(
1129 import_reticle_document(&xml),
1130 Err(ReticleError::InvalidSpec(_))
1131 ));
1132 }
1133
1134 #[test]
1135 fn entities_in_the_name_are_decoded() {
1136 let description = import_reticle_document(
1137 r#"<reticle name="Duplex & BDC "Pro" — 3x" />"#,
1138 )
1139 .unwrap();
1140 assert_eq!(description.name, r#"Duplex & BDC "Pro" — 3x"#);
1141
1142 // An entity this module does not know stays verbatim rather than failing the import.
1143 let odd = import_reticle_document(r#"<reticle name="A B &broken C" />"#).unwrap();
1144 assert_eq!(odd.name, "A B &broken C");
1145 }
1146
1147 #[test]
1148 fn an_imported_ladder_hold_solves() {
1149 let description = import_reticle_document(LADDER_MOA).unwrap();
1150 // A 5 MOA drop lands exactly on the middle holdover.
1151 let hold = hold_point_in_reticle(5.0 * MOA_TO_MIL, 0.0, 1.0, &description).unwrap();
1152 assert!(hold.nearest_mark.is_some());
1153 assert!(
1154 hold.nearest_mark_distance_mil < 1e-9,
1155 "the hold sits on the mark (distance {})",
1156 hold.nearest_mark_distance_mil
1157 );
1158 assert!(!hold.off_reticle);
1159
1160 // And the documented cost of the hold/decoration rule, asserted rather than
1161 // described: the drawn windage hash was dropped, so the windage axis is degenerate
1162 // and any wind hold reads off-reticle.
1163 let windy = hold_point_in_reticle(5.0 * MOA_TO_MIL, 0.5, 1.0, &description).unwrap();
1164 assert!(
1165 windy.off_reticle,
1166 "with elevation-only marks, any wind hold is off-reticle by construction"
1167 );
1168 }
1169}