Skip to main content

target_match/
footprint.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Captured sky-footprint comparison and union geometry.
6//!
7//! A [`SkyFootprint`] carries an ordered solved boundary, its centre, solved
8//! sky position angle, image parity, and caller-owned provenance. Pair
9//! comparison projects both boundaries onto the spherical midpoint's gnomonic
10//! plane. [`FootprintUnion`] keeps one tangent-plane anchor for its lifetime so
11//! disconnected components, holes, point containment, and object coverage all
12//! use the same geometry.
13//!
14//! All areas are measured in squared dimensionless gnomonic coordinates. The
15//! polygon implementation is private; callers receive measurements and typed
16//! evidence rather than planar geometry types.
17
18use core::cmp::Ordering;
19
20use geo::coordinate_position::{CoordPos, CoordinatePosition};
21use geo::line_intersection::line_intersection;
22use geo::orient::Direction;
23use geo::{
24    Area, BooleanOps, BoundingRect, Coord, Line, LineString, MultiPolygon, Orient, Point, Polygon,
25    Rotate,
26};
27use skymath::{
28    gnomonic_project, gnomonic_unproject, separation, transport_position_angle, Angle, Equatorial,
29    GnomonicPoint,
30};
31
32use crate::error::{Error, Result};
33
34const MAX_ROTATION_SAMPLES: usize = 1_000_000;
35const MAX_ELLIPSE_SEGMENTS: usize = 65_536;
36const FULL_COVERAGE_EPSILON: f64 = 1e-10;
37
38/// Whether a solved image transform preserves or mirrors handedness.
39///
40/// Parity is reported separately from sky-axis rotation. It never changes the
41/// footprint's polygon or rotation residual.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44pub enum ImageParity {
45    /// The image transform preserves handedness.
46    Direct,
47    /// The image transform mirrors handedness.
48    Mirrored,
49}
50
51/// Opaque caller-supplied identity for the evidence behind a footprint.
52///
53/// The value can be a stable image, panel, WCS-solution, or evidence digest.
54/// `target-match` compares and returns it but does not interpret it.
55#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
56#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
57#[cfg_attr(feature = "serde", serde(try_from = "String"))]
58pub struct FootprintProvenance(String);
59
60impl FootprintProvenance {
61    /// Construct a non-empty provenance identity.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`Error::InvalidFootprint`] when `value` is empty or only
66    /// whitespace.
67    pub fn new(value: impl Into<String>) -> Result<Self> {
68        let value = value.into();
69        if value.trim().is_empty() {
70            return Err(Error::InvalidFootprint {
71                provenance: value,
72                reason: "provenance must not be empty".into(),
73            });
74        }
75        Ok(Self(value))
76    }
77
78    /// Return the caller-supplied identity.
79    #[must_use]
80    pub fn as_str(&self) -> &str {
81        &self.0
82    }
83}
84
85#[cfg(feature = "serde")]
86impl TryFrom<String> for FootprintProvenance {
87    type Error = Error;
88
89    fn try_from(value: String) -> Result<Self> {
90        Self::new(value)
91    }
92}
93
94/// An ordered solved image boundary on the sky.
95///
96/// Corners may describe any simple polygon. The boundary must stay within the
97/// gnomonic horizon of `centre`, contain `centre`, and use the same coordinate
98/// epoch as `centre`.
99#[derive(Debug, Clone, PartialEq)]
100#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
101#[cfg_attr(feature = "serde", serde(try_from = "UncheckedSkyFootprint"))]
102pub struct SkyFootprint {
103    centre: Equatorial,
104    corners: Vec<Equatorial>,
105    sky_position_angle: Angle,
106    parity: ImageParity,
107    provenance: FootprintProvenance,
108}
109
110#[cfg(feature = "serde")]
111#[derive(serde::Deserialize)]
112struct UncheckedSkyFootprint {
113    centre: Equatorial,
114    corners: Vec<Equatorial>,
115    sky_position_angle: Angle,
116    parity: ImageParity,
117    provenance: FootprintProvenance,
118}
119
120#[cfg(feature = "serde")]
121impl TryFrom<UncheckedSkyFootprint> for SkyFootprint {
122    type Error = Error;
123
124    fn try_from(value: UncheckedSkyFootprint) -> Result<Self> {
125        Self::new(
126            value.centre,
127            value.corners,
128            value.sky_position_angle,
129            value.parity,
130            value.provenance,
131        )
132    }
133}
134
135impl SkyFootprint {
136    /// Construct and validate a solved footprint.
137    ///
138    /// A repeated final corner equal to the first is accepted and removed.
139    ///
140    /// # Errors
141    ///
142    /// Returns a typed footprint, epoch, or projection error for an invalid
143    /// boundary.
144    pub fn new(
145        centre: Equatorial,
146        mut corners: Vec<Equatorial>,
147        sky_position_angle: Angle,
148        parity: ImageParity,
149        provenance: FootprintProvenance,
150    ) -> Result<Self> {
151        if corners.first() == corners.last() && corners.len() > 1 {
152            corners.pop();
153        }
154        if corners.len() < 3 {
155            return Err(invalid_footprint(
156                &provenance,
157                "boundary must contain at least three distinct corners",
158            ));
159        }
160        if !sky_position_angle.degrees().is_finite() {
161            return Err(invalid_footprint(
162                &provenance,
163                "sky position angle must be finite",
164            ));
165        }
166        for corner in &corners {
167            ensure_epoch(centre, *corner)?;
168        }
169
170        let footprint = Self {
171            centre,
172            corners,
173            sky_position_angle: sky_position_angle.normalized_0_360(),
174            parity,
175            provenance,
176        };
177        let polygon = project_polygon(centre, &footprint)?;
178        if polygon.coordinate_position(&Coord { x: 0.0, y: 0.0 }) == CoordPos::Outside {
179            return Err(invalid_footprint(
180                &footprint.provenance,
181                "boundary does not contain its centre",
182            ));
183        }
184        Ok(footprint)
185    }
186
187    /// Solved footprint centre.
188    #[must_use]
189    pub fn centre(&self) -> Equatorial {
190        self.centre
191    }
192
193    /// Ordered sky boundary without a repeated closing corner.
194    #[must_use]
195    pub fn corners(&self) -> &[Equatorial] {
196        &self.corners
197    }
198
199    /// Solved sky position angle, East of North and normalized to `[0, 360)`°.
200    #[must_use]
201    pub fn sky_position_angle(&self) -> Angle {
202        self.sky_position_angle
203    }
204
205    /// Solved image parity.
206    #[must_use]
207    pub fn parity(&self) -> ImageParity {
208        self.parity
209    }
210
211    /// Evidence identity supplied by the caller.
212    #[must_use]
213    pub fn provenance(&self) -> &FootprintProvenance {
214        &self.provenance
215    }
216
217    /// Longest great-circle separation between any two boundary corners.
218    #[must_use]
219    pub fn diagonal(&self) -> Angle {
220        let mut maximum = 0.0_f64;
221        for (index, left) in self.corners.iter().enumerate() {
222            for right in &self.corners[index + 1..] {
223                maximum = maximum.max(separation(*left, *right).radians());
224            }
225        }
226        Angle::from_radians(maximum)
227    }
228}
229
230/// Measured relationship between two captured footprints.
231#[derive(Debug, Clone, Copy, PartialEq)]
232#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
233pub struct FootprintComparison {
234    /// Spherical-midpoint anchor of the common gnomonic plane.
235    pub anchor: Equatorial,
236    /// Left footprint area in the common plane.
237    pub left_area: f64,
238    /// Right footprint area in the common plane.
239    pub right_area: f64,
240    /// Intersection area in the common plane.
241    pub intersection_area: f64,
242    /// `intersection_area / min(left_area, right_area)`.
243    pub normalized_coverage: f64,
244    /// Great-circle separation between footprint centres.
245    pub centre_separation: Angle,
246    /// Smaller footprint's longest great-circle corner-to-corner diagonal.
247    pub smaller_diagonal: Angle,
248    /// Centre separation divided by `smaller_diagonal`.
249    pub normalized_centre_separation: f64,
250    /// Right solved sky axis relative to left after transport to `anchor`.
251    ///
252    /// The result is normalized modulo 180° into `[-90, 90)`.
253    pub residual_sky_rotation: Angle,
254    /// Whether the independently supplied image parities match.
255    pub parity_match: bool,
256}
257
258/// Compare two footprints on their deterministic common plane.
259///
260/// # Errors
261///
262/// Returns a typed error for mismatched epochs, antipodal centres, projection
263/// failure, or invalid projected geometry.
264pub fn compare_footprints(
265    left: &SkyFootprint,
266    right: &SkyFootprint,
267) -> Result<FootprintComparison> {
268    PairGeometry::new(left, right)?.comparison(left, right)
269}
270
271/// Inclusive normalized-coverage band supplied by the caller.
272#[derive(Debug, Clone, Copy, PartialEq)]
273#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
274#[cfg_attr(feature = "serde", serde(try_from = "UncheckedCoverageBand"))]
275pub struct CoverageBand {
276    minimum: f64,
277    maximum: f64,
278}
279
280#[cfg(feature = "serde")]
281#[derive(serde::Deserialize)]
282struct UncheckedCoverageBand {
283    minimum: f64,
284    maximum: f64,
285}
286
287#[cfg(feature = "serde")]
288impl TryFrom<UncheckedCoverageBand> for CoverageBand {
289    type Error = Error;
290
291    fn try_from(value: UncheckedCoverageBand) -> Result<Self> {
292        Self::new(value.minimum, value.maximum)
293    }
294}
295
296impl CoverageBand {
297    /// Construct an inclusive coverage band within `[0, 1]`.
298    ///
299    /// # Errors
300    ///
301    /// Returns [`Error::InvalidCoverageBand`] for non-finite, out-of-range, or
302    /// reversed bounds.
303    pub fn new(minimum: f64, maximum: f64) -> Result<Self> {
304        if !minimum.is_finite()
305            || !maximum.is_finite()
306            || minimum < 0.0
307            || maximum > 1.0
308            || minimum > maximum
309        {
310            return Err(Error::InvalidCoverageBand(format!(
311                "expected 0 <= minimum <= maximum <= 1, got {minimum}..={maximum}"
312            )));
313        }
314        Ok(Self { minimum, maximum })
315    }
316
317    /// Inclusive lower bound.
318    #[must_use]
319    pub fn minimum(self) -> f64 {
320        self.minimum
321    }
322
323    /// Inclusive upper bound.
324    #[must_use]
325    pub fn maximum(self) -> f64 {
326        self.maximum
327    }
328
329    fn contains(self, coverage: f64) -> bool {
330        (self.minimum..=self.maximum).contains(&coverage)
331    }
332}
333
334/// Caller-controlled numerical search domain for coverage rotation intervals.
335///
336/// `sample_step` determines the narrowest interval the grid can discover.
337/// Each detected transition is bisected to `tolerance`.
338#[derive(Debug, Clone, Copy, PartialEq)]
339#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
340#[cfg_attr(feature = "serde", serde(try_from = "UncheckedRotationSearch"))]
341pub struct RotationSearch {
342    minimum: Angle,
343    maximum: Angle,
344    sample_step: Angle,
345    tolerance: Angle,
346}
347
348#[cfg(feature = "serde")]
349#[derive(serde::Deserialize)]
350struct UncheckedRotationSearch {
351    minimum: Angle,
352    maximum: Angle,
353    sample_step: Angle,
354    tolerance: Angle,
355}
356
357#[cfg(feature = "serde")]
358impl TryFrom<UncheckedRotationSearch> for RotationSearch {
359    type Error = Error;
360
361    fn try_from(value: UncheckedRotationSearch) -> Result<Self> {
362        Self::new(
363            value.minimum,
364            value.maximum,
365            value.sample_step,
366            value.tolerance,
367        )
368    }
369}
370
371impl RotationSearch {
372    /// Construct a finite increasing search domain and positive resolutions.
373    ///
374    /// # Errors
375    ///
376    /// Returns [`Error::InvalidRotationSearch`] when the domain or resolutions
377    /// are invalid or require more than one million samples.
378    pub fn new(
379        minimum: Angle,
380        maximum: Angle,
381        sample_step: Angle,
382        tolerance: Angle,
383    ) -> Result<Self> {
384        let values = [
385            minimum.degrees(),
386            maximum.degrees(),
387            sample_step.degrees(),
388            tolerance.degrees(),
389        ];
390        if values.iter().any(|value| !value.is_finite())
391            || values[0] >= values[1]
392            || values[2] <= 0.0
393            || values[3] <= 0.0
394            || values[3] > values[2]
395        {
396            return Err(Error::InvalidRotationSearch(format!(
397                "expected finite min < max and 0 < tolerance <= sample step, got {}..{} step {} tolerance {} degrees",
398                values[0], values[1], values[2], values[3]
399            )));
400        }
401        checked_rotation_sample_count(values[0], values[1], values[2])?;
402        Ok(Self {
403            minimum,
404            maximum,
405            sample_step,
406            tolerance,
407        })
408    }
409
410    /// Inclusive search-domain start.
411    #[must_use]
412    pub fn minimum(self) -> Angle {
413        self.minimum
414    }
415
416    /// Inclusive search-domain end.
417    #[must_use]
418    pub fn maximum(self) -> Angle {
419        self.maximum
420    }
421
422    /// Grid spacing used to discover intervals.
423    #[must_use]
424    pub fn sample_step(self) -> Angle {
425        self.sample_step
426    }
427
428    /// Maximum bisection width for a discovered transition.
429    #[must_use]
430    pub fn tolerance(self) -> Angle {
431        self.tolerance
432    }
433}
434
435/// Closed residual-rotation interval whose coverage lies inside a band.
436#[derive(Debug, Clone, Copy, PartialEq)]
437#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
438pub struct RotationInterval {
439    /// Inclusive interval start in the caller's rotation domain.
440    pub start: Angle,
441    /// Inclusive interval end in the caller's rotation domain.
442    pub end: Angle,
443}
444
445/// Measure normalized coverage at a caller-supplied residual sky rotation.
446///
447/// The right footprint rotates about its own projected centre from its observed
448/// transported residual to `residual`. Parity remains unchanged and is not
449/// folded into this value.
450pub fn coverage_at_residual_rotation(
451    left: &SkyFootprint,
452    right: &SkyFootprint,
453    residual: Angle,
454) -> Result<f64> {
455    if !residual.degrees().is_finite() {
456        return Err(Error::InvalidRotationSearch(
457            "residual rotation must be finite".into(),
458        ));
459    }
460    PairGeometry::new(left, right)?.coverage_at(residual.degrees())
461}
462
463/// Find closed residual-rotation intervals whose coverage lies in `band`.
464///
465/// The result can contain multiple disjoint intervals. Discovery resolution is
466/// controlled by [`RotationSearch::sample_step`]; transition endpoints are
467/// refined to [`RotationSearch::tolerance`].
468pub fn coverage_rotation_intervals(
469    left: &SkyFootprint,
470    right: &SkyFootprint,
471    band: CoverageBand,
472    search: RotationSearch,
473) -> Result<Vec<RotationInterval>> {
474    let pair = PairGeometry::new(left, right)?;
475    let minimum = search.minimum.degrees();
476    let maximum = search.maximum.degrees();
477    let step = search.sample_step.degrees();
478    let tolerance = search.tolerance.degrees();
479
480    let sample_count = checked_rotation_sample_count(minimum, maximum, step)?;
481    let mut samples = Vec::with_capacity(sample_count);
482    samples.push(minimum);
483    for index in 1..sample_count - 1 {
484        let angle = minimum + step * index as f64;
485        if angle > *samples.last().expect("minimum sample exists") && angle < maximum {
486            samples.push(angle);
487        }
488    }
489    samples.push(maximum);
490
491    let mut intervals = Vec::new();
492    let mut previous_angle = samples[0];
493    let mut previous_inside = band.contains(pair.coverage_at(previous_angle)?);
494    let mut interval_start = previous_inside.then_some(previous_angle);
495
496    for current_angle in samples.into_iter().skip(1) {
497        let current_inside = band.contains(pair.coverage_at(current_angle)?);
498        if previous_inside != current_inside {
499            let transition = bisect_transition(
500                &pair,
501                band,
502                previous_angle,
503                current_angle,
504                previous_inside,
505                tolerance,
506            )?;
507            if current_inside {
508                interval_start = Some(transition);
509            } else if let Some(start) = interval_start.take() {
510                intervals.push(RotationInterval {
511                    start: Angle::from_degrees(start),
512                    end: Angle::from_degrees(transition),
513                });
514            }
515        }
516        previous_angle = current_angle;
517        previous_inside = current_inside;
518    }
519
520    if previous_inside {
521        intervals.push(RotationInterval {
522            start: Angle::from_degrees(interval_start.unwrap_or(minimum)),
523            end: Angle::from_degrees(maximum),
524        });
525    }
526    Ok(intervals)
527}
528
529/// Position of a point relative to a footprint union or one of its members.
530#[derive(Debug, Clone, Copy, PartialEq, Eq)]
531#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
532pub enum Containment {
533    /// The point is outside the captured area.
534    Outside,
535    /// The point lies on a captured boundary and counts as covered.
536    Boundary,
537    /// The point lies inside the captured area.
538    Interior,
539}
540
541impl Containment {
542    /// Whether the point counts as covered under the inclusive-boundary rule.
543    #[must_use]
544    pub fn is_covered(self) -> bool {
545        self != Self::Outside
546    }
547}
548
549/// Point-containment evidence for one input panel.
550#[derive(Debug, Clone, PartialEq, Eq)]
551#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
552pub struct PanelContainmentEvidence {
553    /// Caller-supplied panel or image evidence identity.
554    pub provenance: FootprintProvenance,
555    /// Position relative to this panel's footprint.
556    pub containment: Containment,
557}
558
559/// Point-containment result for the captured union.
560#[derive(Debug, Clone, PartialEq, Eq)]
561#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
562pub struct PointContainmentEvidence {
563    /// Position relative to the complete captured union.
564    pub containment: Containment,
565    /// Stable indices of union components that contain or bound the point.
566    pub component_indices: Vec<usize>,
567    /// Per-panel evidence, sorted by provenance.
568    pub panels: Vec<PanelContainmentEvidence>,
569}
570
571/// A tangent-plane ellipse on the sky.
572///
573/// The semi-axis angles are converted to gnomonic radii around `centre`. The
574/// sampled boundary is then projected to a union's persisted anchor.
575#[derive(Debug, Clone, Copy, PartialEq)]
576#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
577#[cfg_attr(feature = "serde", serde(try_from = "UncheckedSkyEllipse"))]
578pub struct SkyEllipse {
579    centre: Equatorial,
580    semi_major: Angle,
581    semi_minor: Angle,
582    sky_position_angle: Angle,
583    segments: usize,
584}
585
586#[cfg(feature = "serde")]
587#[derive(serde::Deserialize)]
588struct UncheckedSkyEllipse {
589    centre: Equatorial,
590    semi_major: Angle,
591    semi_minor: Angle,
592    sky_position_angle: Angle,
593    segments: usize,
594}
595
596#[cfg(feature = "serde")]
597impl TryFrom<UncheckedSkyEllipse> for SkyEllipse {
598    type Error = Error;
599
600    fn try_from(value: UncheckedSkyEllipse) -> Result<Self> {
601        Self::new(
602            value.centre,
603            value.semi_major,
604            value.semi_minor,
605            value.sky_position_angle,
606            value.segments,
607        )
608    }
609}
610
611impl SkyEllipse {
612    /// Construct a sampled sky ellipse.
613    ///
614    /// # Errors
615    ///
616    /// Returns [`Error::InvalidEllipse`] unless both semi-axes are finite,
617    /// positive, below 90°, ordered major then minor, and sampled with 8 through
618    /// 65,536 segments.
619    pub fn new(
620        centre: Equatorial,
621        semi_major: Angle,
622        semi_minor: Angle,
623        sky_position_angle: Angle,
624        segments: usize,
625    ) -> Result<Self> {
626        let major = semi_major.radians();
627        let minor = semi_minor.radians();
628        if !major.is_finite()
629            || !minor.is_finite()
630            || !sky_position_angle.degrees().is_finite()
631            || major <= 0.0
632            || minor <= 0.0
633            || major < minor
634            || major >= core::f64::consts::FRAC_PI_2
635            || !(8..=MAX_ELLIPSE_SEGMENTS).contains(&segments)
636        {
637            return Err(Error::InvalidEllipse(format!(
638                "expected 0 < semi_minor <= semi_major < 90 degrees and 8..={MAX_ELLIPSE_SEGMENTS} segments"
639            )));
640        }
641        Ok(Self {
642            centre,
643            semi_major,
644            semi_minor,
645            sky_position_angle: sky_position_angle.normalized_0_360(),
646            segments,
647        })
648    }
649
650    /// Ellipse centre.
651    #[must_use]
652    pub fn centre(self) -> Equatorial {
653        self.centre
654    }
655
656    /// Major semi-axis.
657    #[must_use]
658    pub fn semi_major(self) -> Angle {
659        self.semi_major
660    }
661
662    /// Minor semi-axis.
663    #[must_use]
664    pub fn semi_minor(self) -> Angle {
665        self.semi_minor
666    }
667
668    /// Major-axis position angle East of North.
669    #[must_use]
670    pub fn sky_position_angle(self) -> Angle {
671        self.sky_position_angle
672    }
673
674    /// Number of boundary segments used for intersection.
675    #[must_use]
676    pub fn segments(self) -> usize {
677        self.segments
678    }
679}
680
681/// Caller-supplied object geometry to measure against a captured union.
682#[derive(Debug, Clone, Copy)]
683pub enum ObjectShape<'a> {
684    /// Point-like or unknown-extent object.
685    Point(Equatorial),
686    /// Ordered object boundary supplied as a validated footprint.
687    Footprint(&'a SkyFootprint),
688    /// Sampled extended-object ellipse.
689    Ellipse(&'a SkyEllipse),
690}
691
692/// Area evidence for one disconnected captured-union component.
693#[derive(Debug, Clone, Copy, PartialEq)]
694#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
695pub struct ComponentCoverageEvidence {
696    /// Stable component index.
697    pub component_index: usize,
698    /// Intersection area with the object geometry.
699    pub intersection_area: f64,
700}
701
702/// Area evidence for one input panel.
703#[derive(Debug, Clone, PartialEq)]
704#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
705pub struct PanelCoverageEvidence {
706    /// Caller-supplied panel or image evidence identity.
707    pub provenance: FootprintProvenance,
708    /// Intersection area with the object geometry.
709    pub intersection_area: f64,
710}
711
712/// Captured-union coverage state for an object.
713#[derive(Debug, Clone, Copy, PartialEq, Eq)]
714#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
715pub enum CoverageState {
716    /// The object has zero captured area, or a point is outside the union.
717    None,
718    /// Some but not all object area is captured.
719    Partial,
720    /// All object area is captured, or a point is inside/on the boundary.
721    Full,
722}
723
724/// Measured object coverage and its component/panel evidence.
725#[derive(Debug, Clone, PartialEq)]
726#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
727pub struct ObjectCoverageEvidence {
728    /// Overall object coverage classification.
729    pub state: CoverageState,
730    /// Captured area divided by object area; absent for point objects.
731    pub covered_fraction: Option<f64>,
732    /// Point evidence; present only for [`ObjectShape::Point`].
733    pub point: Option<PointContainmentEvidence>,
734    /// Positive-area intersections by stable union component.
735    pub components: Vec<ComponentCoverageEvidence>,
736    /// Positive-area intersections by input panel, sorted by provenance.
737    pub panels: Vec<PanelCoverageEvidence>,
738}
739
740/// Hole-aware union of captured footprints on one persisted gnomonic plane.
741#[derive(Debug, Clone)]
742pub struct FootprintUnion {
743    anchor: Equatorial,
744    geometry: MultiPolygon<f64>,
745    panels: Vec<ProjectedPanel>,
746}
747
748impl FootprintUnion {
749    /// Build a union while preserving disconnected components and interior holes.
750    ///
751    /// Input order does not affect the anchor, component ordering, or panel
752    /// evidence ordering. Provenance identities must be unique.
753    ///
754    /// # Errors
755    ///
756    /// Returns a typed empty-set, duplicate-provenance, epoch, antipodal,
757    /// projection, or invalid-geometry error.
758    pub fn new(footprints: &[SkyFootprint]) -> Result<Self> {
759        if footprints.is_empty() {
760            return Err(Error::EmptyFootprintSet);
761        }
762
763        let mut ordered: Vec<&SkyFootprint> = footprints.iter().collect();
764        ordered.sort_by(|left, right| left.provenance.cmp(&right.provenance));
765        for pair in ordered.windows(2) {
766            if pair[0].provenance == pair[1].provenance {
767                return Err(Error::DuplicateFootprintProvenance(
768                    pair[0].provenance.0.clone(),
769                ));
770            }
771        }
772
773        let centres: Vec<Equatorial> = ordered.iter().map(|footprint| footprint.centre).collect();
774        let anchor = common_anchor(&centres)?;
775        let mut panels = Vec::with_capacity(ordered.len());
776        let mut union: Option<MultiPolygon<f64>> = None;
777
778        for footprint in ordered {
779            let polygon = project_polygon(anchor, footprint)?;
780            union = Some(match union {
781                None => MultiPolygon(vec![polygon.clone()]),
782                Some(current) => current.union(&MultiPolygon(vec![polygon.clone()])),
783            });
784            panels.push(ProjectedPanel {
785                provenance: footprint.provenance.clone(),
786                polygon,
787            });
788        }
789
790        let mut components = union.expect("non-empty input creates a union").0;
791        components.sort_by(compare_polygons);
792        Ok(Self {
793            anchor,
794            geometry: MultiPolygon(components),
795            panels,
796        })
797    }
798
799    /// Persisted common-plane anchor used by all union operations.
800    #[must_use]
801    pub fn anchor(&self) -> Equatorial {
802        self.anchor
803    }
804
805    /// Total captured area in squared gnomonic coordinates.
806    #[must_use]
807    pub fn area(&self) -> f64 {
808        self.geometry.unsigned_area()
809    }
810
811    /// Number of disconnected captured components.
812    #[must_use]
813    pub fn component_count(&self) -> usize {
814        self.geometry.0.len()
815    }
816
817    /// Total number of interior holes across all components.
818    #[must_use]
819    pub fn hole_count(&self) -> usize {
820        self.geometry
821            .0
822            .iter()
823            .map(|polygon| polygon.interiors().len())
824            .sum()
825    }
826
827    /// Measure inclusive-boundary point containment with component/panel evidence.
828    pub fn contains_point(&self, point: Equatorial) -> Result<PointContainmentEvidence> {
829        let projected = project_position(self.anchor, point, "object point")?;
830        let coordinate = Coord {
831            x: projected.east,
832            y: projected.north,
833        };
834        let union_containment = containment(self.geometry.coordinate_position(&coordinate));
835
836        let component_indices = self
837            .geometry
838            .0
839            .iter()
840            .enumerate()
841            .filter_map(|(index, polygon)| {
842                let position = containment(polygon.coordinate_position(&coordinate));
843                position.is_covered().then_some(index)
844            })
845            .collect();
846        let panels = self
847            .panels
848            .iter()
849            .map(|panel| PanelContainmentEvidence {
850                provenance: panel.provenance.clone(),
851                containment: containment(panel.polygon.coordinate_position(&coordinate)),
852            })
853            .collect();
854
855        Ok(PointContainmentEvidence {
856            containment: union_containment,
857            component_indices,
858            panels,
859        })
860    }
861
862    /// Measure point, footprint, or ellipse coverage against the captured union.
863    pub fn measure_object(&self, object: ObjectShape<'_>) -> Result<ObjectCoverageEvidence> {
864        match object {
865            ObjectShape::Point(point) => {
866                let evidence = self.contains_point(point)?;
867                let state = if evidence.containment.is_covered() {
868                    CoverageState::Full
869                } else {
870                    CoverageState::None
871                };
872                Ok(ObjectCoverageEvidence {
873                    state,
874                    covered_fraction: None,
875                    point: Some(evidence),
876                    components: Vec::new(),
877                    panels: Vec::new(),
878                })
879            }
880            ObjectShape::Footprint(footprint) => {
881                let polygon = project_polygon(self.anchor, footprint)?;
882                self.measure_polygon(&polygon)
883            }
884            ObjectShape::Ellipse(ellipse) => {
885                let polygon = project_ellipse(self.anchor, ellipse)?;
886                self.measure_polygon(&polygon)
887            }
888        }
889    }
890
891    fn measure_polygon(&self, object: &Polygon<f64>) -> Result<ObjectCoverageEvidence> {
892        let object_area = object.unsigned_area();
893        if !object_area.is_finite() || object_area <= 0.0 {
894            return Err(Error::InvalidObjectGeometry(
895                "projected object area must be finite and positive".into(),
896            ));
897        }
898        let object_multi = MultiPolygon(vec![object.clone()]);
899        let intersection_area = self.geometry.intersection(&object_multi).unsigned_area();
900        let covered_fraction = (intersection_area / object_area).clamp(0.0, 1.0);
901        let state = if intersection_area <= 0.0 {
902            CoverageState::None
903        } else if covered_fraction >= 1.0 - FULL_COVERAGE_EPSILON {
904            CoverageState::Full
905        } else {
906            CoverageState::Partial
907        };
908
909        let components = self
910            .geometry
911            .0
912            .iter()
913            .enumerate()
914            .filter_map(|(component_index, component)| {
915                let area = component.intersection(object).unsigned_area();
916                (area > 0.0).then_some(ComponentCoverageEvidence {
917                    component_index,
918                    intersection_area: area,
919                })
920            })
921            .collect();
922        let panels = self
923            .panels
924            .iter()
925            .filter_map(|panel| {
926                let area = panel.polygon.intersection(object).unsigned_area();
927                (area > 0.0).then_some(PanelCoverageEvidence {
928                    provenance: panel.provenance.clone(),
929                    intersection_area: area,
930                })
931            })
932            .collect();
933
934        Ok(ObjectCoverageEvidence {
935            state,
936            covered_fraction: Some(covered_fraction),
937            point: None,
938            components,
939            panels,
940        })
941    }
942}
943
944#[derive(Debug, Clone)]
945struct ProjectedPanel {
946    provenance: FootprintProvenance,
947    polygon: Polygon<f64>,
948}
949
950struct PairGeometry {
951    anchor: Equatorial,
952    left: Polygon<f64>,
953    right: Polygon<f64>,
954    right_centre: Point<f64>,
955    left_area: f64,
956    right_area: f64,
957    observed_residual_degrees: f64,
958}
959
960impl PairGeometry {
961    fn new(left: &SkyFootprint, right: &SkyFootprint) -> Result<Self> {
962        let anchor = common_anchor(&[left.centre, right.centre])?;
963        let left_polygon = project_polygon(anchor, left)?;
964        let right_polygon = project_polygon(anchor, right)?;
965        let right_centre = project_position(anchor, right.centre, right.provenance.as_str())?;
966        let left_axis = transport_position_angle(left.centre, anchor, left.sky_position_angle)
967            .ok_or(Error::AntipodalGeometry)?;
968        let right_axis = transport_position_angle(right.centre, anchor, right.sky_position_angle)
969            .ok_or(Error::AntipodalGeometry)?;
970
971        Ok(Self {
972            anchor,
973            left_area: left_polygon.unsigned_area(),
974            right_area: right_polygon.unsigned_area(),
975            left: left_polygon,
976            right: right_polygon,
977            right_centre: Point::new(right_centre.east, right_centre.north),
978            observed_residual_degrees: axis_residual_degrees(
979                left_axis.degrees(),
980                right_axis.degrees(),
981            ),
982        })
983    }
984
985    fn comparison(&self, left: &SkyFootprint, right: &SkyFootprint) -> Result<FootprintComparison> {
986        let smaller_area = self.left_area.min(self.right_area);
987        let intersection_area = self.left.intersection(&self.right).unsigned_area();
988        let smaller_diagonal = if left.diagonal().radians() <= right.diagonal().radians() {
989            left.diagonal()
990        } else {
991            right.diagonal()
992        };
993        if smaller_area <= 0.0 || smaller_diagonal.radians() <= 0.0 {
994            return Err(Error::InvalidObjectGeometry(
995                "footprint area and diagonal must be positive".into(),
996            ));
997        }
998        let centre_separation = separation(left.centre, right.centre);
999        Ok(FootprintComparison {
1000            anchor: self.anchor,
1001            left_area: self.left_area,
1002            right_area: self.right_area,
1003            intersection_area,
1004            normalized_coverage: (intersection_area / smaller_area).clamp(0.0, 1.0),
1005            centre_separation,
1006            smaller_diagonal,
1007            normalized_centre_separation: centre_separation.radians() / smaller_diagonal.radians(),
1008            residual_sky_rotation: Angle::from_degrees(self.observed_residual_degrees),
1009            parity_match: left.parity == right.parity,
1010        })
1011    }
1012
1013    fn coverage_at(&self, residual_degrees: f64) -> Result<f64> {
1014        if !residual_degrees.is_finite() {
1015            return Err(Error::InvalidRotationSearch(
1016                "residual rotation must be finite".into(),
1017            ));
1018        }
1019        let delta = residual_degrees - self.observed_residual_degrees;
1020        // Sky position angle increases clockwise in an East/North plane; geo's
1021        // positive rotation is counter-clockwise.
1022        let rotated = self.right.rotate_around_point(-delta, self.right_centre);
1023        let intersection_area = self.left.intersection(&rotated).unsigned_area();
1024        Ok((intersection_area / self.left_area.min(self.right_area)).clamp(0.0, 1.0))
1025    }
1026}
1027
1028fn bisect_transition(
1029    pair: &PairGeometry,
1030    band: CoverageBand,
1031    mut lower: f64,
1032    mut upper: f64,
1033    lower_inside: bool,
1034    tolerance: f64,
1035) -> Result<f64> {
1036    while upper - lower > tolerance {
1037        let middle = lower + (upper - lower) / 2.0;
1038        if band.contains(pair.coverage_at(middle)?) == lower_inside {
1039            lower = middle;
1040        } else {
1041            upper = middle;
1042        }
1043    }
1044    Ok(if lower_inside { lower } else { upper })
1045}
1046
1047fn checked_rotation_sample_count(minimum: f64, maximum: f64, step: f64) -> Result<usize> {
1048    let span = maximum - minimum;
1049    let intervals = span / step;
1050    let maximum_intervals = (MAX_ROTATION_SAMPLES - 1) as f64;
1051    let required_intervals = intervals.ceil().max(1.0);
1052    if !span.is_finite() || !intervals.is_finite() || required_intervals > maximum_intervals {
1053        return Err(Error::InvalidRotationSearch(format!(
1054            "search exceeds the maximum of {MAX_ROTATION_SAMPLES} samples"
1055        )));
1056    }
1057    Ok(required_intervals as usize + 1)
1058}
1059
1060fn common_anchor(positions: &[Equatorial]) -> Result<Equatorial> {
1061    let first = *positions.first().ok_or(Error::EmptyFootprintSet)?;
1062    for position in &positions[1..] {
1063        ensure_epoch(first, *position)?;
1064    }
1065
1066    let mut ordered = positions.to_vec();
1067    ordered.sort_by(|left, right| {
1068        left.ra()
1069            .degrees()
1070            .total_cmp(&right.ra().degrees())
1071            .then_with(|| left.dec().degrees().total_cmp(&right.dec().degrees()))
1072    });
1073    let sum = ordered.iter().fold([0.0_f64; 3], |mut sum, position| {
1074        let (ra, dec) = (position.ra().radians(), position.dec().radians());
1075        let vector = [dec.cos() * ra.cos(), dec.cos() * ra.sin(), dec.sin()];
1076        sum[0] += vector[0];
1077        sum[1] += vector[1];
1078        sum[2] += vector[2];
1079        sum
1080    });
1081    let norm = sum[0].hypot(sum[1]).hypot(sum[2]);
1082    if !norm.is_finite() || norm <= 1e-12 {
1083        return Err(Error::AntipodalGeometry);
1084    }
1085    let vector = [sum[0] / norm, sum[1] / norm, sum[2] / norm];
1086    let mut right_ascension = vector[1].atan2(vector[0]).to_degrees().rem_euclid(360.0);
1087    if right_ascension >= 360.0 {
1088        right_ascension = 0.0;
1089    }
1090    Equatorial::at_epoch(
1091        Angle::from_degrees(right_ascension),
1092        Angle::from_radians(vector[2].clamp(-1.0, 1.0).asin()),
1093        first.epoch(),
1094    )
1095    .map_err(|error| Error::InvalidObjectGeometry(error.to_string()))
1096}
1097
1098fn project_polygon(anchor: Equatorial, footprint: &SkyFootprint) -> Result<Polygon<f64>> {
1099    ensure_epoch(anchor, footprint.centre)?;
1100    let coordinates: Result<Vec<Coord<f64>>> = footprint
1101        .corners
1102        .iter()
1103        .map(|corner| {
1104            let point = project_position(anchor, *corner, footprint.provenance.as_str())?;
1105            Ok(Coord {
1106                x: point.east,
1107                y: point.north,
1108            })
1109        })
1110        .collect();
1111    polygon_from_coordinates(coordinates?, &footprint.provenance)
1112}
1113
1114fn polygon_from_coordinates(
1115    coordinates: Vec<Coord<f64>>,
1116    provenance: &FootprintProvenance,
1117) -> Result<Polygon<f64>> {
1118    if coordinates.len() < 3 {
1119        return Err(invalid_footprint(
1120            provenance,
1121            "projected boundary must contain at least three corners",
1122        ));
1123    }
1124    for index in 0..coordinates.len() {
1125        if coordinates[index] == coordinates[(index + 1) % coordinates.len()] {
1126            return Err(invalid_footprint(
1127                provenance,
1128                "boundary contains a zero-length edge",
1129            ));
1130        }
1131    }
1132    for left_index in 0..coordinates.len() {
1133        let left = Line::new(
1134            coordinates[left_index],
1135            coordinates[(left_index + 1) % coordinates.len()],
1136        );
1137        for right_index in left_index + 1..coordinates.len() {
1138            let adjacent = right_index == left_index + 1
1139                || (left_index == 0 && right_index == coordinates.len() - 1);
1140            if adjacent {
1141                continue;
1142            }
1143            let right = Line::new(
1144                coordinates[right_index],
1145                coordinates[(right_index + 1) % coordinates.len()],
1146            );
1147            if line_intersection(left, right).is_some() {
1148                return Err(invalid_footprint(provenance, "boundary self-intersects"));
1149            }
1150        }
1151    }
1152
1153    let mut closed = coordinates;
1154    closed.push(closed[0]);
1155    let polygon = Polygon::new(LineString::new(closed), Vec::new()).orient(Direction::Default);
1156    let area = polygon.unsigned_area();
1157    if !area.is_finite() || area <= 0.0 {
1158        return Err(invalid_footprint(
1159            provenance,
1160            "projected boundary area must be finite and positive",
1161        ));
1162    }
1163    Ok(polygon)
1164}
1165
1166fn project_ellipse(anchor: Equatorial, ellipse: &SkyEllipse) -> Result<Polygon<f64>> {
1167    ensure_epoch(anchor, ellipse.centre)?;
1168    let major = ellipse.semi_major.radians().tan();
1169    let minor = ellipse.semi_minor.radians().tan();
1170    let position_angle = ellipse.sky_position_angle.radians();
1171    let (sin_pa, cos_pa) = position_angle.sin_cos();
1172    let mut coordinates = Vec::with_capacity(ellipse.segments);
1173
1174    for index in 0..ellipse.segments {
1175        let phase = core::f64::consts::TAU * index as f64 / ellipse.segments as f64;
1176        let (sin_phase, cos_phase) = phase.sin_cos();
1177        let east = major * cos_phase * sin_pa + minor * sin_phase * cos_pa;
1178        let north = major * cos_phase * cos_pa - minor * sin_phase * sin_pa;
1179        let sky = gnomonic_unproject(ellipse.centre, GnomonicPoint { east, north })
1180            .ok_or_else(|| Error::ProjectionFailed("object ellipse".into()))?;
1181        let projected = project_position(anchor, sky, "object ellipse")?;
1182        coordinates.push(Coord {
1183            x: projected.east,
1184            y: projected.north,
1185        });
1186    }
1187    polygon_from_coordinates(coordinates, &FootprintProvenance("object ellipse".into()))
1188        .map_err(|error| Error::InvalidObjectGeometry(error.to_string()))
1189}
1190
1191fn project_position(
1192    anchor: Equatorial,
1193    position: Equatorial,
1194    context: &str,
1195) -> Result<GnomonicPoint> {
1196    ensure_epoch(anchor, position)?;
1197    gnomonic_project(anchor, position).ok_or_else(|| Error::ProjectionFailed(context.to_owned()))
1198}
1199
1200fn ensure_epoch(left: Equatorial, right: Equatorial) -> Result<()> {
1201    if left.epoch() == right.epoch() {
1202        Ok(())
1203    } else {
1204        Err(Error::FootprintEpochMismatch)
1205    }
1206}
1207
1208fn axis_residual_degrees(left: f64, right: f64) -> f64 {
1209    (right - left + 90.0).rem_euclid(180.0) - 90.0
1210}
1211
1212fn containment(position: CoordPos) -> Containment {
1213    match position {
1214        CoordPos::Outside => Containment::Outside,
1215        CoordPos::OnBoundary => Containment::Boundary,
1216        CoordPos::Inside => Containment::Interior,
1217    }
1218}
1219
1220fn compare_polygons(left: &Polygon<f64>, right: &Polygon<f64>) -> Ordering {
1221    let left_rect = left
1222        .bounding_rect()
1223        .expect("validated polygon has a bounding rectangle");
1224    let right_rect = right
1225        .bounding_rect()
1226        .expect("validated polygon has a bounding rectangle");
1227    [
1228        left_rect.min().x.total_cmp(&right_rect.min().x),
1229        left_rect.min().y.total_cmp(&right_rect.min().y),
1230        left_rect.max().x.total_cmp(&right_rect.max().x),
1231        left_rect.max().y.total_cmp(&right_rect.max().y),
1232        left.unsigned_area().total_cmp(&right.unsigned_area()),
1233    ]
1234    .into_iter()
1235    .find(|ordering| *ordering != Ordering::Equal)
1236    .unwrap_or(Ordering::Equal)
1237}
1238
1239fn invalid_footprint(provenance: &FootprintProvenance, reason: &str) -> Error {
1240    Error::InvalidFootprint {
1241        provenance: provenance.0.clone(),
1242        reason: reason.into(),
1243    }
1244}
1245
1246#[cfg(test)]
1247mod tests {
1248    use super::*;
1249
1250    #[test]
1251    fn axis_residual_is_modulo_180() {
1252        assert_eq!(axis_residual_degrees(10.0, 190.0), 0.0);
1253        assert_eq!(axis_residual_degrees(350.0, 10.0), 20.0);
1254        assert_eq!(axis_residual_degrees(10.0, 100.0), -90.0);
1255    }
1256}