1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
44pub enum ImageParity {
45 Direct,
47 Mirrored,
49}
50
51#[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 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 #[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#[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 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 #[must_use]
189 pub fn centre(&self) -> Equatorial {
190 self.centre
191 }
192
193 #[must_use]
195 pub fn corners(&self) -> &[Equatorial] {
196 &self.corners
197 }
198
199 #[must_use]
201 pub fn sky_position_angle(&self) -> Angle {
202 self.sky_position_angle
203 }
204
205 #[must_use]
207 pub fn parity(&self) -> ImageParity {
208 self.parity
209 }
210
211 #[must_use]
213 pub fn provenance(&self) -> &FootprintProvenance {
214 &self.provenance
215 }
216
217 #[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#[derive(Debug, Clone, Copy, PartialEq)]
232#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
233pub struct FootprintComparison {
234 pub anchor: Equatorial,
236 pub left_area: f64,
238 pub right_area: f64,
240 pub intersection_area: f64,
242 pub normalized_coverage: f64,
244 pub centre_separation: Angle,
246 pub smaller_diagonal: Angle,
248 pub normalized_centre_separation: f64,
250 pub residual_sky_rotation: Angle,
254 pub parity_match: bool,
256}
257
258pub fn compare_footprints(
265 left: &SkyFootprint,
266 right: &SkyFootprint,
267) -> Result<FootprintComparison> {
268 PairGeometry::new(left, right)?.comparison(left, right)
269}
270
271#[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 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 #[must_use]
319 pub fn minimum(self) -> f64 {
320 self.minimum
321 }
322
323 #[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#[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 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 #[must_use]
412 pub fn minimum(self) -> Angle {
413 self.minimum
414 }
415
416 #[must_use]
418 pub fn maximum(self) -> Angle {
419 self.maximum
420 }
421
422 #[must_use]
424 pub fn sample_step(self) -> Angle {
425 self.sample_step
426 }
427
428 #[must_use]
430 pub fn tolerance(self) -> Angle {
431 self.tolerance
432 }
433}
434
435#[derive(Debug, Clone, Copy, PartialEq)]
437#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
438pub struct RotationInterval {
439 pub start: Angle,
441 pub end: Angle,
443}
444
445pub 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
463pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
531#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
532pub enum Containment {
533 Outside,
535 Boundary,
537 Interior,
539}
540
541impl Containment {
542 #[must_use]
544 pub fn is_covered(self) -> bool {
545 self != Self::Outside
546 }
547}
548
549#[derive(Debug, Clone, PartialEq, Eq)]
551#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
552pub struct PanelContainmentEvidence {
553 pub provenance: FootprintProvenance,
555 pub containment: Containment,
557}
558
559#[derive(Debug, Clone, PartialEq, Eq)]
561#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
562pub struct PointContainmentEvidence {
563 pub containment: Containment,
565 pub component_indices: Vec<usize>,
567 pub panels: Vec<PanelContainmentEvidence>,
569}
570
571#[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 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 #[must_use]
652 pub fn centre(self) -> Equatorial {
653 self.centre
654 }
655
656 #[must_use]
658 pub fn semi_major(self) -> Angle {
659 self.semi_major
660 }
661
662 #[must_use]
664 pub fn semi_minor(self) -> Angle {
665 self.semi_minor
666 }
667
668 #[must_use]
670 pub fn sky_position_angle(self) -> Angle {
671 self.sky_position_angle
672 }
673
674 #[must_use]
676 pub fn segments(self) -> usize {
677 self.segments
678 }
679}
680
681#[derive(Debug, Clone, Copy)]
683pub enum ObjectShape<'a> {
684 Point(Equatorial),
686 Footprint(&'a SkyFootprint),
688 Ellipse(&'a SkyEllipse),
690}
691
692#[derive(Debug, Clone, Copy, PartialEq)]
694#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
695pub struct ComponentCoverageEvidence {
696 pub component_index: usize,
698 pub intersection_area: f64,
700}
701
702#[derive(Debug, Clone, PartialEq)]
704#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
705pub struct PanelCoverageEvidence {
706 pub provenance: FootprintProvenance,
708 pub intersection_area: f64,
710}
711
712#[derive(Debug, Clone, Copy, PartialEq, Eq)]
714#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
715pub enum CoverageState {
716 None,
718 Partial,
720 Full,
722}
723
724#[derive(Debug, Clone, PartialEq)]
726#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
727pub struct ObjectCoverageEvidence {
728 pub state: CoverageState,
730 pub covered_fraction: Option<f64>,
732 pub point: Option<PointContainmentEvidence>,
734 pub components: Vec<ComponentCoverageEvidence>,
736 pub panels: Vec<PanelCoverageEvidence>,
738}
739
740#[derive(Debug, Clone)]
742pub struct FootprintUnion {
743 anchor: Equatorial,
744 geometry: MultiPolygon<f64>,
745 panels: Vec<ProjectedPanel>,
746}
747
748impl FootprintUnion {
749 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(¢res)?;
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 #[must_use]
801 pub fn anchor(&self) -> Equatorial {
802 self.anchor
803 }
804
805 #[must_use]
807 pub fn area(&self) -> f64 {
808 self.geometry.unsigned_area()
809 }
810
811 #[must_use]
813 pub fn component_count(&self) -> usize {
814 self.geometry.0.len()
815 }
816
817 #[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 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 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 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}