From 9428b17e3b803abed392c710bc3dbac5fb8d8110 Mon Sep 17 00:00:00 2001 From: Nick P Date: Tue, 14 Jul 2026 17:14:05 -0600 Subject: [PATCH] feat: complete policy parity --- crates/geometry-overlay/src/lib.rs | 5 +- crates/geometry-overlay/src/validity.rs | 273 ++++++++- crates/geometry-strategy/src/compare.rs | 747 ++++++++++++++++++++++++ crates/geometry-strategy/src/lib.rs | 2 + crates/geometry/src/prelude.rs | 6 +- crates/geometry/tests/policy_parity.rs | 257 ++++++++ docs/crates/geometry-overlay.md | 2 +- docs/crates/geometry-strategy.md | 8 + 8 files changed, 1274 insertions(+), 26 deletions(-) create mode 100644 crates/geometry-strategy/src/compare.rs create mode 100644 crates/geometry/tests/policy_parity.rs diff --git a/crates/geometry-overlay/src/lib.rs b/crates/geometry-overlay/src/lib.rs index f5f4bd7..48a2f0b 100644 --- a/crates/geometry-overlay/src/lib.rs +++ b/crates/geometry-overlay/src/lib.rs @@ -53,4 +53,7 @@ pub use relate::{ relate_mask as relate, touches, }; pub use surface_point::point_on_surface; -pub use validity::{ValidityFailure, is_valid, is_valid_polygon, is_valid_ring}; +pub use validity::{ + ValidityFailure, ValidityOptions, is_valid, is_valid_polygon, is_valid_polygon_with, + is_valid_ring, is_valid_ring_with, is_valid_with, validity_reason, validity_reason_with, +}; diff --git a/crates/geometry-overlay/src/validity.rs b/crates/geometry-overlay/src/validity.rs index 8fdba4a..af7b783 100644 --- a/crates/geometry-overlay/src/validity.rs +++ b/crates/geometry-overlay/src/validity.rs @@ -15,10 +15,10 @@ //! Coordinate validity (NaN / infinity) is checked because the robustness //! gate depends on finite input. //! -//! Unlike Boost's default validity policy, which permits consecutive repeated -//! points, this Rust entry selects Boost's strict-policy behavior and returns -//! [`ValidityFailure::DuplicatePoints`]. Boost exercises that policy in -//! `test/algorithms/is_valid.cpp:1626-1634`. +//! [`is_valid`] preserves this crate's strict behavior for compatibility. +//! [`is_valid_with`] accepts [`ValidityOptions`], including +//! [`ValidityOptions::BOOST_DEFAULT`] which permits consecutive repeated +//! points like `policies/is_valid/default_policy.hpp:26-61`. use alloc::vec::Vec; @@ -37,10 +37,10 @@ use crate::predicate::segment_intersection::{SegmentIntersection, segment_inters /// Why a geometry failed [`is_valid_ring`] / [`is_valid_polygon`]. /// -/// Mirrors the subset of Boost's `validity_failure_type` -/// (`algorithms/validity_failure_type.hpp:33-106`) that the areal validator -/// can produce. The numeric groupings (few-points, not-closed, -/// self-intersections, …) match Boost's categories. +/// Mirrors Boost's complete `validity_failure_type` taxonomy +/// (`algorithms/validity_failure_type.hpp:33-113`). The current areal +/// validator produces the relevant ring/polygon variants; retaining the +/// remaining categories keeps reporting stable as kind dispatch expands. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ValidityFailure { /// Fewer than the 4 points a closed ring needs (3 distinct + the @@ -88,6 +88,126 @@ pub enum ValidityFailure { /// Distinct multi-polygon members overlap in area or share a boundary /// curve. Boost's `failure_intersecting_interiors`. IntersectingInteriors, + /// The geometry collapses below its declared topological dimension. + /// Boost's `failure_wrong_topological_dimension`. + WrongTopologicalDimension, + /// A box's maximum corner is lexicographically before its minimum corner. + /// Boost's `failure_wrong_corner_order`. + WrongCornerOrder, + /// Collinear vertices occur on one polyhedral-surface face. Boost's + /// `failure_collinear_points_on_face`. + CollinearPointsOnFace, + /// Vertices of one polyhedral-surface face are not coplanar. Boost's + /// `failure_non_coplanar_points_on_face`. + NonCoplanarPointsOnFace, + /// A polyhedral-surface face contains too few vertices. Boost's + /// `failure_few_points_on_face`. + FewPointsOnFace, + /// A polyhedral-surface edge has inconsistent face orientation. Boost's + /// `failure_inconsistent_orientation`. + InconsistentOrientation, + /// Polyhedral-surface faces intersect away from a shared edge. Boost's + /// `failure_invalid_intersection`. + InvalidIntersection, + /// Polyhedral-surface faces do not form a connected surface. Boost's + /// `failure_disconnected_surface`. + DisconnectedSurface, +} + +impl ValidityFailure { + /// Return the stable reason prefix for this failure. + /// + /// The areal, linear, box, and coordinate strings are byte-for-byte the + /// messages returned by `validity_failure_type_message` in + /// `policies/is_valid/failing_reason_policy.hpp:32-63`. Surface messages + /// extend that table for the surface failure values added later in + /// `algorithms/validity_failure_type.hpp:91-113`. + #[must_use] + pub const fn message(self) -> &'static str { + match self { + Self::FewPoints => "Geometry has too few points", + Self::WrongTopologicalDimension => "Geometry has wrong topological dimension", + Self::Spikes => "Geometry has spikes", + Self::DuplicatePoints => "Geometry has duplicate (consecutive) points", + Self::NotClosed => "Geometry is defined as closed but is open", + Self::SelfIntersection => "Geometry has invalid self-intersections", + Self::WrongOrientation => "Geometry has wrong orientation", + Self::InteriorRingOutside => { + "Geometry has interior rings defined outside the outer boundary" + } + Self::NestedInteriorRings => "Geometry has nested interior rings", + Self::DisconnectedInterior => "Geometry has disconnected interior", + Self::IntersectingInteriors => "Multi-polygon has intersecting interiors", + Self::WrongCornerOrder => "Box has corners in wrong order", + Self::InvalidCoordinate => "Geometry has point(s) with invalid coordinate(s)", + Self::CoordinateOutOfRange => { + "Geometry has coordinate(s) outside the supported arithmetic range" + } + Self::CollinearPointsOnFace => "Geometry has collinear points on a face", + Self::NonCoplanarPointsOnFace => "Geometry has non-coplanar points on a face", + Self::FewPointsOnFace => "Geometry has too few points on a face", + Self::InconsistentOrientation => "Geometry has inconsistent surface orientation", + Self::InvalidIntersection => "Geometry has invalid face intersections", + Self::DisconnectedSurface => "Geometry has a disconnected surface", + } + } +} + +impl core::fmt::Display for ValidityFailure { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter.write_str(self.message()) + } +} + +#[cfg(feature = "std")] +impl std::error::Error for ValidityFailure {} + +/// Behavior switches applied by [`is_valid_with`]. +/// +/// Mirrors the `AllowDuplicates` and `AllowSpikes` template parameters of +/// `policies/is_valid/default_policy.hpp:26-61`. The current validator covers +/// areal geometries, so `allow_spikes_for_linear` is recorded for API parity +/// but only becomes observable when linear validity dispatch is added. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ValidityOptions { + allow_duplicates: bool, + allow_spikes_for_linear: bool, +} + +impl ValidityOptions { + /// Existing Rust behavior: report duplicates and spikes. + pub const STRICT: Self = Self::new(false, false); + + /// Boost's default validity behavior: permit duplicate points and spikes + /// in linear geometries. + pub const BOOST_DEFAULT: Self = Self::new(true, true); + + /// Construct validity behavior from Boost's two policy switches. + #[must_use] + pub const fn new(allow_duplicates: bool, allow_spikes_for_linear: bool) -> Self { + Self { + allow_duplicates, + allow_spikes_for_linear, + } + } + + /// Whether consecutive duplicate points are accepted. + #[must_use] + pub const fn allows_duplicates(self) -> bool { + self.allow_duplicates + } + + /// Whether spikes are accepted when linear validity dispatch is used. + #[must_use] + pub const fn allows_spikes_for_linear(self) -> bool { + self.allow_spikes_for_linear + } +} + +impl Default for ValidityOptions { + fn default() -> Self { + Self::STRICT + } } /// Per-kind validity implementation selected by [`is_valid`]. @@ -96,7 +216,7 @@ pub enum ValidityFailure { /// `algorithms/detail/is_valid/interface.hpp:153-203`. #[doc(hidden)] pub trait ValidityStrategy { - fn apply(&self, geometry: &G) -> Result<(), ValidityFailure>; + fn apply(&self, geometry: &G, options: ValidityOptions) -> Result<(), ValidityFailure>; } /// Tag-to-validity implementation picker. @@ -170,7 +290,59 @@ where G::Kind: ValidityStrategyForKind, ::S: ValidityStrategy, { - <::S as Default>::default().apply(geometry) + is_valid_with(geometry, ValidityOptions::STRICT) +} + +/// Validate an areal geometry with explicit validity behavior. +/// +/// Mirrors the policy-taking overload behind +/// `algorithms/detail/is_valid/interface.hpp:155-202`. Use +/// [`ValidityOptions::BOOST_DEFAULT`] to select Boost's default handling of +/// consecutive duplicates, or [`ValidityOptions::STRICT`] for the behavior of +/// [`is_valid`]. +/// +/// # Errors +/// +/// Returns the first [`ValidityFailure`] not accepted by `options`. +#[inline] +#[must_use = "validity failures must be handled"] +pub fn is_valid_with(geometry: &G, options: ValidityOptions) -> Result<(), ValidityFailure> +where + G: Geometry, + G::Kind: ValidityStrategyForKind, + ::S: ValidityStrategy, +{ + <::S as Default>::default().apply(geometry, options) +} + +/// Return Boost's human-readable reason for strict validation. +/// +/// This is the allocation-free Rust counterpart to the string-output overload +/// driven by `policies/is_valid/failing_reason_policy.hpp`. +#[inline] +#[must_use] +pub fn validity_reason(geometry: &G) -> &'static str +where + G: Geometry, + G::Kind: ValidityStrategyForKind, + ::S: ValidityStrategy, +{ + validity_reason_with(geometry, ValidityOptions::STRICT) +} + +/// Return Boost's human-readable reason using explicit validity behavior. +#[inline] +#[must_use] +pub fn validity_reason_with(geometry: &G, options: ValidityOptions) -> &'static str +where + G: Geometry, + G::Kind: ValidityStrategyForKind, + ::S: ValidityStrategy, +{ + match is_valid_with(geometry, options) { + Ok(()) => "Geometry is valid", + Err(failure) => failure.message(), + } } /// Implements the ring validity arm selected by @@ -182,8 +354,8 @@ where P::Scalar: CoordinateScalar + Into, ::Family: SameAs, { - fn apply(&self, ring: &G) -> Result<(), ValidityFailure> { - is_valid_ring(ring) + fn apply(&self, ring: &G, options: ValidityOptions) -> Result<(), ValidityFailure> { + is_valid_ring_with(ring, options) } } @@ -196,8 +368,8 @@ where P::Scalar: CoordinateScalar + Into, ::Family: SameAs, { - fn apply(&self, polygon: &G) -> Result<(), ValidityFailure> { - is_valid_polygon(polygon) + fn apply(&self, polygon: &G, options: ValidityOptions) -> Result<(), ValidityFailure> { + is_valid_polygon_with(polygon, options) } } @@ -210,10 +382,10 @@ where P::Scalar: CoordinateScalar + Into, ::Family: SameAs, { - fn apply(&self, multi_polygon: &G) -> Result<(), ValidityFailure> { + fn apply(&self, multi_polygon: &G, options: ValidityOptions) -> Result<(), ValidityFailure> { let polygons: Vec<_> = multi_polygon.polygons().collect(); for polygon in &polygons { - is_valid_polygon(*polygon)?; + is_valid_polygon_with(*polygon, options)?; } for first in 0..polygons.len() { for second in (first + 1)..polygons.len() { @@ -268,7 +440,24 @@ where P::Scalar: CoordinateScalar + Into, ::Family: SameAs, { - validate_ring(ring, false) + is_valid_ring_with(ring, ValidityOptions::STRICT) +} + +/// Validate one ring with explicit validity behavior. +/// +/// # Errors +/// +/// Returns the first [`ValidityFailure`] not accepted by `options`. +#[inline] +#[must_use = "validity failures must be handled"] +pub fn is_valid_ring_with(ring: &R, options: ValidityOptions) -> Result<(), ValidityFailure> +where + R: RingTrait, + P: PointMut + Default + Copy, + P::Scalar: CoordinateScalar + Into, + ::Family: SameAs, +{ + validate_ring(ring, false, options) } /// Shared ring validation. `is_interior` flips the orientation @@ -276,14 +465,18 @@ where /// (strategy-level `ShoelaceArea` positive); an interior ring winds /// opposite (negative) — mirroring Boost's /// `is_properly_oriented`. -fn validate_ring(ring: &R, is_interior: bool) -> Result<(), ValidityFailure> +fn validate_ring( + ring: &R, + is_interior: bool, + options: ValidityOptions, +) -> Result<(), ValidityFailure> where R: RingTrait, P: PointMut + Default + Copy, P::Scalar: CoordinateScalar + Into, ::Family: SameAs, { - let pts: Vec

= ring.points().copied().collect(); + let mut pts: Vec

= ring.points().copied().collect(); // Coordinate finiteness. for p in &pts { @@ -294,6 +487,22 @@ where } } + // Boost's default policy accepts consecutive duplicates. Remove them + // before count, spike, intersection, and orientation checks so a + // zero-length edge cannot create a secondary failure. + if options.allows_duplicates() { + let mut deduplicated = Vec::with_capacity(pts.len()); + for point in pts { + if !deduplicated + .last() + .is_some_and(|previous| same_point(previous, &point)) + { + deduplicated.push(point); + } + } + pts = deduplicated; + } + // Out-of-range coordinates: the self-intersection test below routes // each edge pair through the segment-intersection kernel, which drops // any crossing at coordinates past ±SAFE_ABS_MAX as `OutOfRange`. A @@ -315,7 +524,7 @@ where return Err(ValidityFailure::NotClosed); } - if pts.windows(2).any(|pair| same_point(&pair[0], &pair[1])) { + if !options.allows_duplicates() && pts.windows(2).any(|pair| same_point(&pair[0], &pair[1])) { return Err(ValidityFailure::DuplicatePoints); } @@ -383,10 +592,30 @@ where P::Scalar: CoordinateScalar + Into, ::Family: SameAs, { - validate_ring(polygon.exterior(), false)?; + is_valid_polygon_with(polygon, ValidityOptions::STRICT) +} + +/// Validate one polygon with explicit validity behavior. +/// +/// # Errors +/// +/// Returns the first [`ValidityFailure`] not accepted by `options`. +#[inline] +#[must_use = "validity failures must be handled"] +pub fn is_valid_polygon_with( + polygon: &G, + options: ValidityOptions, +) -> Result<(), ValidityFailure> +where + G: PolygonTrait, + P: PointMut + Default + Copy, + P::Scalar: CoordinateScalar + Into, + ::Family: SameAs, +{ + validate_ring(polygon.exterior(), false, options)?; let inners: Vec<_> = polygon.interiors().collect(); for inner in &inners { - validate_ring(*inner, true)?; + validate_ring(*inner, true, options)?; if let Some(rep) = inner.points().next() { if !WithinRing.covered_by(rep, polygon.exterior()) { return Err(ValidityFailure::InteriorRingOutside); diff --git a/crates/geometry-strategy/src/compare.rs b/crates/geometry-strategy/src/compare.rs new file mode 100644 index 0000000..1989336 --- /dev/null +++ b/crates/geometry-strategy/src/compare.rs @@ -0,0 +1,747 @@ +//! Point-ordering policies. +//! +//! Mirrors `boost/geometry/policies/compare.hpp` and the default Cartesian, +//! spherical, and geographic strategies selected from +//! `boost/geometry/strategies/{compare,spherical/compare}.hpp`. +//! +//! The default dimension (`-1`) compares lexicographically across the +//! dimensions shared by both points. A non-negative const parameter compares +//! only that dimension. Spherical and geographic longitude comparison treats +//! `-180°` and `180°` as the same meridian, orders the antimeridian after +//! ordinary longitudes, and ignores longitude differences at a shared pole. + +use core::cmp::Ordering; + +use geometry_coords::{CoordinateScalar, Rational, RationalInteger}; +use geometry_cs::{ + AngleUnit, CartesianFamily, CoordinateSystem, Geographic, GeographicFamily, Spherical, + SphericalFamily, +}; +use geometry_tag::SameAs; +use geometry_trait::Point; + +/// Sentinel used by the comparison policies to inspect all shared dimensions. +pub const ALL_DIMENSIONS: i8 = -1; + +/// Sort points in lexicographically ascending order using epsilon equality. +/// +/// Mirrors `boost::geometry::less` from `policies/compare.hpp:75-265`. +#[derive(Debug, Default, Clone, Copy)] +pub struct Less; + +/// Sort points in lexicographically ascending order using exact equality. +/// +/// Mirrors `boost::geometry::less_exact` from +/// `policies/compare.hpp:35-73`. +#[derive(Debug, Default, Clone, Copy)] +pub struct LessExact; + +/// Sort points in lexicographically descending order using epsilon equality. +/// +/// Mirrors `boost::geometry::greater` from +/// `policies/compare.hpp:268-367`. +#[derive(Debug, Default, Clone, Copy)] +pub struct Greater; + +/// Test points for coordinate-wise epsilon equality. +/// +/// Mirrors `boost::geometry::equal_to` from +/// `policies/compare.hpp:370-470`. +#[derive(Debug, Default, Clone, Copy)] +pub struct EqualTo; + +impl Less { + /// Return whether `left` precedes `right` under this policy. + /// + /// # Panics + /// + /// Panics when `DIMENSION` is neither [`ALL_DIMENSIONS`] nor a dimension + /// present in both points. + #[inline] + #[must_use] + #[allow( + clippy::unused_self, + reason = "value-method policy objects are directly usable as sorting functors" + )] + pub fn apply(self, left: &P1, right: &P2) -> bool + where + P1: Point, + P2: Point, + ::Family: ComparisonFamily, + { + <::Family as ComparisonFamily>::less( + left, right, DIMENSION, false, + ) + } +} + +impl LessExact { + /// Return whether `left` exactly precedes `right` under this policy. + /// + /// # Panics + /// + /// Panics when `DIMENSION` is neither [`ALL_DIMENSIONS`] nor a dimension + /// present in both points. + #[inline] + #[must_use] + #[allow( + clippy::unused_self, + reason = "value-method policy objects are directly usable as sorting functors" + )] + pub fn apply(self, left: &P1, right: &P2) -> bool + where + P1: Point, + P2: Point, + ::Family: ComparisonFamily, + { + <::Family as ComparisonFamily>::less( + left, right, DIMENSION, true, + ) + } +} + +impl Greater { + /// Return whether `left` follows `right` under this policy. + /// + /// # Panics + /// + /// Panics when `DIMENSION` is neither [`ALL_DIMENSIONS`] nor a dimension + /// present in both points. + #[inline] + #[must_use] + #[allow( + clippy::unused_self, + reason = "value-method policy objects are directly usable as sorting functors" + )] + pub fn apply(self, left: &P1, right: &P2) -> bool + where + P1: Point, + P2: Point, + ::Family: ComparisonFamily, + { + <::Family as ComparisonFamily>::greater( + left, right, DIMENSION, + ) + } +} + +impl EqualTo { + /// Return whether the selected coordinates compare equal within epsilon. + /// + /// # Panics + /// + /// Panics when `DIMENSION` is neither [`ALL_DIMENSIONS`] nor a dimension + /// present in both points. + #[inline] + #[must_use] + #[allow( + clippy::unused_self, + reason = "value-method policy objects are directly usable as sorting functors" + )] + pub fn apply(self, left: &P1, right: &P2) -> bool + where + P1: Point, + P2: Point, + ::Family: ComparisonFamily, + { + <::Family as ComparisonFamily>::equal( + left, right, DIMENSION, + ) + } +} + +/// Coordinate-family dispatch used by [`Less`], [`Greater`], and [`EqualTo`]. +/// +/// This is public only because it appears in the generic policy method bounds; +/// downstream implementations are sealed. +#[doc(hidden)] +pub trait ComparisonFamily: sealed::ComparisonFamily { + #[doc(hidden)] + fn less(left: &P1, right: &P2, dimension: i8, exact: bool) -> bool; + + #[doc(hidden)] + fn greater(left: &P1, right: &P2, dimension: i8) -> bool; + + #[doc(hidden)] + fn equal(left: &P1, right: &P2, dimension: i8) -> bool; +} + +mod sealed { + pub trait ComparisonFamily {} + + impl ComparisonFamily for geometry_cs::CartesianFamily {} + impl ComparisonFamily for geometry_cs::SphericalFamily {} + impl ComparisonFamily for geometry_cs::GeographicFamily {} +} + +impl ComparisonFamily for CartesianFamily +where + P1: Point, + P2: Point, + ::Family: SameAs, + ::Family: SameAs, + P1::Scalar: CoordinateComparison, +{ + #[inline] + fn less(left: &P1, right: &P2, dimension: i8, exact: bool) -> bool { + cartesian_compare(left, right, dimension, Relation::Less, exact) + } + + #[inline] + fn greater(left: &P1, right: &P2, dimension: i8) -> bool { + cartesian_compare(left, right, dimension, Relation::Greater, false) + } + + #[inline] + fn equal(left: &P1, right: &P2, dimension: i8) -> bool { + cartesian_compare(left, right, dimension, Relation::Equal, false) + } +} + +impl ComparisonFamily for SphericalFamily +where + P1: Point, + P2: Point, + P1::Cs: AngularCoordinateSystem, + P2::Cs: AngularCoordinateSystem, + ::Family: SameAs, + ::Family: SameAs, + P1::Scalar: AngularScalar + CoordinateComparison, + P2::Scalar: AngularScalar, +{ + #[inline] + fn less(left: &P1, right: &P2, dimension: i8, exact: bool) -> bool { + angular_compare(left, right, dimension, Relation::Less, exact) + } + + #[inline] + fn greater(left: &P1, right: &P2, dimension: i8) -> bool { + angular_compare(left, right, dimension, Relation::Greater, false) + } + + #[inline] + fn equal(left: &P1, right: &P2, dimension: i8) -> bool { + angular_compare(left, right, dimension, Relation::Equal, false) + } +} + +impl ComparisonFamily for GeographicFamily +where + P1: Point, + P2: Point, + P1::Cs: AngularCoordinateSystem, + P2::Cs: AngularCoordinateSystem, + ::Family: SameAs, + ::Family: SameAs, + P1::Scalar: AngularScalar + CoordinateComparison, + P2::Scalar: AngularScalar, +{ + #[inline] + fn less(left: &P1, right: &P2, dimension: i8, exact: bool) -> bool { + angular_compare(left, right, dimension, Relation::Less, exact) + } + + #[inline] + fn greater(left: &P1, right: &P2, dimension: i8) -> bool { + angular_compare(left, right, dimension, Relation::Greater, false) + } + + #[inline] + fn equal(left: &P1, right: &P2, dimension: i8) -> bool { + angular_compare(left, right, dimension, Relation::Equal, false) + } +} + +#[derive(Clone, Copy)] +enum Relation { + Less, + Greater, + Equal, +} + +fn cartesian_compare( + left: &P1, + right: &P2, + dimension: i8, + relation: Relation, + exact: bool, +) -> bool +where + P1: Point, + P2: Point, + P1::Scalar: CoordinateComparison, +{ + let shared_dimensions = P1::DIM.min(P2::DIM); + validate_dimension(dimension, shared_dimensions); + + if dimension == ALL_DIMENSIONS { + for index in 0..shared_dimensions { + let result = compare_ordinate(left, right, index, relation, exact); + if let Some(result) = result { + return result; + } + } + matches!(relation, Relation::Equal) + } else { + compare_ordinate(left, right, dimension_index(dimension), relation, exact) + .unwrap_or(matches!(relation, Relation::Equal)) + } +} + +fn angular_compare( + left: &P1, + right: &P2, + dimension: i8, + relation: Relation, + exact: bool, +) -> bool +where + P1: Point, + P2: Point, + P1::Cs: AngularCoordinateSystem, + P2::Cs: AngularCoordinateSystem, + P1::Scalar: AngularScalar + CoordinateComparison, + P2::Scalar: AngularScalar, +{ + let shared_dimensions = P1::DIM.min(P2::DIM); + validate_dimension(dimension, shared_dimensions); + + if dimension >= 2 { + return compare_ordinate(left, right, dimension_index(dimension), relation, exact) + .unwrap_or(matches!(relation, Relation::Equal)); + } + + if dimension == 1 { + return angular_latitude(left, right, relation, exact); + } + + let longitude = angular_longitude(left, right, relation, exact); + if let Some(result) = longitude { + return result; + } + if dimension == 0 { + return matches!(relation, Relation::Equal); + } + + let latitude = angular_latitude_result(left, right, relation, exact); + if let Some(result) = latitude { + return result; + } + for index in 2..shared_dimensions { + if let Some(result) = compare_ordinate(left, right, index, relation, exact) { + return result; + } + } + matches!(relation, Relation::Equal) +} + +fn angular_longitude(left: &P1, right: &P2, relation: Relation, exact: bool) -> Option +where + P1: Point, + P2: Point, + P1::Cs: AngularCoordinateSystem, + P2::Cs: AngularCoordinateSystem, + P1::Scalar: AngularScalar, + P2::Scalar: AngularScalar, +{ + let left_longitude = P1::Cs::to_radians(left.get::<0>()); + let right_longitude = P2::Cs::to_radians(right.get::<0>()); + let epsilon = angular_epsilon::(); + let coordinates_equal = values_equal(left_longitude, right_longitude, epsilon, exact); + let left_antimeridian = is_antimeridian(left_longitude, epsilon, exact); + let right_antimeridian = is_antimeridian(right_longitude, epsilon, exact); + + let left_latitude = P1::Cs::to_radians(left.get::<1>()); + let right_latitude = P2::Cs::to_radians(right.get::<1>()); + let same_latitude = values_equal(left_latitude, right_latitude, epsilon, exact); + let shared_pole = same_latitude && is_pole(left_latitude, epsilon, exact); + + if coordinates_equal || (left_antimeridian && right_antimeridian) || shared_pole { + None + } else if left_antimeridian { + Some(apply_ordering(Ordering::Greater, relation)) + } else if right_antimeridian { + Some(apply_ordering(Ordering::Less, relation)) + } else { + Some(apply_partial_order( + left_longitude.partial_cmp(&right_longitude), + relation, + )) + } +} + +fn angular_latitude(left: &P1, right: &P2, relation: Relation, exact: bool) -> bool +where + P1: Point, + P2: Point, + P1::Cs: AngularCoordinateSystem, + P2::Cs: AngularCoordinateSystem, + P1::Scalar: AngularScalar, + P2::Scalar: AngularScalar, +{ + angular_latitude_result(left, right, relation, exact) + .unwrap_or(matches!(relation, Relation::Equal)) +} + +fn angular_latitude_result( + left: &P1, + right: &P2, + relation: Relation, + exact: bool, +) -> Option +where + P1: Point, + P2: Point, + P1::Cs: AngularCoordinateSystem, + P2::Cs: AngularCoordinateSystem, + P1::Scalar: AngularScalar, + P2::Scalar: AngularScalar, +{ + let left_latitude = P1::Cs::to_radians(left.get::<1>()); + let right_latitude = P2::Cs::to_radians(right.get::<1>()); + let epsilon = angular_epsilon::(); + if values_equal(left_latitude, right_latitude, epsilon, exact) { + None + } else { + Some(apply_partial_order( + left_latitude.partial_cmp(&right_latitude), + relation, + )) + } +} + +fn compare_ordinate( + left: &P1, + right: &P2, + dimension: usize, + relation: Relation, + exact: bool, +) -> Option +where + P1: Point, + P2: Point, + P1::Scalar: CoordinateComparison, +{ + let (ordering, epsilon_equal) = match dimension { + 0 => left.get::<0>().compare_coordinate(right.get::<0>()), + 1 => left.get::<1>().compare_coordinate(right.get::<1>()), + 2 => left.get::<2>().compare_coordinate(right.get::<2>()), + 3 => left.get::<3>().compare_coordinate(right.get::<3>()), + _ => unreachable!("Point dimensions above four are not supported"), + }; + let equal = if exact { + ordering == Some(Ordering::Equal) + } else { + epsilon_equal + }; + if equal { + None + } else { + Some(apply_partial_order(ordering, relation)) + } +} + +fn apply_partial_order(ordering: Option, relation: Relation) -> bool { + ordering.is_some_and(|ordering| apply_ordering(ordering, relation)) +} + +fn apply_ordering(ordering: Ordering, relation: Relation) -> bool { + match relation { + Relation::Less => ordering == Ordering::Less, + Relation::Greater => ordering == Ordering::Greater, + Relation::Equal => false, + } +} + +fn validate_dimension(dimension: i8, shared_dimensions: usize) { + assert!( + shared_dimensions <= 4, + "Point dimensions above four are not supported" + ); + assert!( + dimension == ALL_DIMENSIONS + || (dimension >= 0 && dimension_index(dimension) < shared_dimensions), + "comparison dimension must be present in both points" + ); +} + +fn dimension_index(dimension: i8) -> usize { + match usize::try_from(dimension) { + Ok(index) => index, + Err(_) => unreachable!("validated comparison dimensions are non-negative"), + } +} + +#[allow(clippy::float_cmp, reason = "exact comparison is a selectable policy")] +fn values_equal(left: f64, right: f64, epsilon: f64, exact: bool) -> bool { + if exact { + left == right + } else { + scaled_equal(left, right, epsilon) + } +} + +#[allow( + clippy::float_cmp, + reason = "exact equality is the required fast path before epsilon scaling" +)] +fn scaled_equal(left: f64, right: f64, epsilon: f64) -> bool { + left == right + || (left.is_finite() + && right.is_finite() + && (left - right).abs() <= epsilon * left.abs().max(right.abs()).max(1.0)) +} + +fn is_antimeridian(value: f64, epsilon: f64, exact: bool) -> bool { + values_equal(value.abs(), core::f64::consts::PI, epsilon, exact) +} + +fn is_pole(value: f64, epsilon: f64, exact: bool) -> bool { + values_equal(value.abs(), core::f64::consts::FRAC_PI_2, epsilon, exact) +} + +fn angular_epsilon() -> f64 { + match (L::EPSILON, R::EPSILON) { + (0.0, right) => right, + (left, 0.0) => left, + (left, right) => left.min(right), + } +} + +/// Scalar conversion used only after angular coordinates are normalized to +/// radians. +#[doc(hidden)] +pub trait AngularScalar: CoordinateScalar { + #[doc(hidden)] + const EPSILON: f64; + + #[doc(hidden)] + fn to_f64(self) -> f64; +} + +impl AngularScalar for f32 { + const EPSILON: f64 = f32::EPSILON as f64; + + #[inline] + fn to_f64(self) -> f64 { + f64::from(self) + } +} + +impl AngularScalar for f64 { + const EPSILON: f64 = f64::EPSILON; + + #[inline] + fn to_f64(self) -> f64 { + self + } +} + +impl AngularScalar for i32 { + const EPSILON: f64 = 0.0; + + #[inline] + fn to_f64(self) -> f64 { + f64::from(self) + } +} + +impl AngularScalar for i64 { + const EPSILON: f64 = 0.0; + + #[inline] + #[allow( + clippy::cast_precision_loss, + reason = "angular normalization uses f64 just like Boost calculation promotion" + )] + fn to_f64(self) -> f64 { + self as f64 + } +} + +impl AngularScalar for Rational { + const EPSILON: f64 = 0.0; + + #[inline] + fn to_f64(self) -> f64 { + self.to_f64() + } +} + +/// Angle-unit extraction for spherical and geographic coordinate systems. +#[doc(hidden)] +pub trait AngularCoordinateSystem: CoordinateSystem { + #[doc(hidden)] + fn to_radians(value: T) -> f64; +} + +impl AngularCoordinateSystem for Spherical { + #[inline] + fn to_radians(value: T) -> f64 { + U::to_radians(value.to_f64()) + } +} + +impl AngularCoordinateSystem for Geographic { + #[inline] + fn to_radians(value: T) -> f64 { + U::to_radians(value.to_f64()) + } +} + +/// Cross-scalar ordering and epsilon comparison for coordinate values. +#[doc(hidden)] +pub trait CoordinateComparison: CoordinateScalar { + #[doc(hidden)] + fn compare_coordinate(self, right: Rhs) -> (Option, bool); +} + +macro_rules! impl_same_float_comparison { + ($type:ty) => { + impl CoordinateComparison<$type> for $type { + #[inline] + fn compare_coordinate(self, right: $type) -> (Option, bool) { + ( + self.partial_cmp(&right), + scaled_equal( + f64::from(self), + f64::from(right), + f64::from(<$type>::EPSILON), + ), + ) + } + } + }; +} + +impl_same_float_comparison!(f32); +impl_same_float_comparison!(f64); + +macro_rules! impl_same_integer_comparison { + ($type:ty) => { + impl CoordinateComparison<$type> for $type { + #[inline] + fn compare_coordinate(self, right: $type) -> (Option, bool) { + (Some(self.cmp(&right)), self == right) + } + } + }; +} + +impl_same_integer_comparison!(i32); +impl_same_integer_comparison!(i64); + +macro_rules! impl_mixed_float_comparison { + ($left:ty, $right:ty, $epsilon:expr) => { + impl CoordinateComparison<$right> for $left { + #[inline] + fn compare_coordinate(self, right: $right) -> (Option, bool) { + let left = <$left as AngularScalar>::to_f64(self); + let right = <$right as AngularScalar>::to_f64(right); + ( + left.partial_cmp(&right), + scaled_equal(left, right, $epsilon), + ) + } + } + }; +} + +impl_mixed_float_comparison!(f32, f64, f64::EPSILON); +impl_mixed_float_comparison!(f64, f32, f64::EPSILON); +impl_mixed_float_comparison!(i32, f32, f64::EPSILON); +impl_mixed_float_comparison!(f32, i32, f64::EPSILON); +impl_mixed_float_comparison!(i32, f64, f64::EPSILON); +impl_mixed_float_comparison!(f64, i32, f64::EPSILON); +impl_mixed_float_comparison!(i64, f32, f64::EPSILON); +impl_mixed_float_comparison!(f32, i64, f64::EPSILON); +impl_mixed_float_comparison!(i64, f64, f64::EPSILON); +impl_mixed_float_comparison!(f64, i64, f64::EPSILON); + +impl CoordinateComparison for i32 { + #[inline] + fn compare_coordinate(self, right: i64) -> (Option, bool) { + let left = i64::from(self); + (Some(left.cmp(&right)), left == right) + } +} + +impl CoordinateComparison for i64 { + #[inline] + fn compare_coordinate(self, right: i32) -> (Option, bool) { + let right = i64::from(right); + (Some(self.cmp(&right)), self == right) + } +} + +impl CoordinateComparison> for Rational +where + I: RationalInteger, + J: RationalInteger, +{ + #[inline] + fn compare_coordinate(self, right: Rational) -> (Option, bool) { + let left_cross = self.numerator().to_i128() * right.denominator().to_i128(); + let right_cross = right.numerator().to_i128() * self.denominator().to_i128(); + let ordering = left_cross.cmp(&right_cross); + (Some(ordering), ordering == Ordering::Equal) + } +} + +macro_rules! impl_rational_integer_comparison { + ($integer:ty) => { + impl CoordinateComparison<$integer> for Rational { + #[inline] + fn compare_coordinate(self, right: $integer) -> (Option, bool) { + let left = self.numerator().to_i128(); + let right = i128::from(right) * self.denominator().to_i128(); + let ordering = left.cmp(&right); + (Some(ordering), ordering == Ordering::Equal) + } + } + + impl CoordinateComparison> for $integer { + #[inline] + fn compare_coordinate(self, right: Rational) -> (Option, bool) { + let left = i128::from(self) * right.denominator().to_i128(); + let right = right.numerator().to_i128(); + let ordering = left.cmp(&right); + (Some(ordering), ordering == Ordering::Equal) + } + } + }; +} + +impl_rational_integer_comparison!(i32); +impl_rational_integer_comparison!(i64); + +macro_rules! impl_rational_float_comparison { + ($float:ty) => { + impl CoordinateComparison<$float> for Rational { + #[inline] + fn compare_coordinate(self, right: $float) -> (Option, bool) { + let left = self.to_f64(); + let right = f64::from(right); + ( + left.partial_cmp(&right), + scaled_equal(left, right, f64::from(<$float>::EPSILON)), + ) + } + } + + impl CoordinateComparison> for $float { + #[inline] + fn compare_coordinate(self, right: Rational) -> (Option, bool) { + let left = f64::from(self); + let right = right.to_f64(); + ( + left.partial_cmp(&right), + scaled_equal(left, right, f64::from(<$float>::EPSILON)), + ) + } + } + }; +} + +impl_rational_float_comparison!(f32); +impl_rational_float_comparison!(f64); diff --git a/crates/geometry-strategy/src/lib.rs b/crates/geometry-strategy/src/lib.rs index 72e522c..60302cb 100644 --- a/crates/geometry-strategy/src/lib.rs +++ b/crates/geometry-strategy/src/lib.rs @@ -107,6 +107,7 @@ pub mod buffer; pub mod cartesian; pub mod centroid; pub mod closest_points; +pub mod compare; pub mod convex_hull; pub mod densify; pub mod disjoint; @@ -141,6 +142,7 @@ pub use centroid::{ CentroidStrategyForKind, }; pub use closest_points::{CartesianClosestPoints, ClosestPointsStrategy}; +pub use compare::{ALL_DIMENSIONS, EqualTo, Greater, Less, LessExact}; pub use convex_hull::{ConvexHullStrategy, MonotoneChain}; pub use densify::{CartesianDensify, DensifyStrategy}; pub use disjoint::{CartesianDisjoint, DisjointStrategy}; diff --git a/crates/geometry/src/prelude.rs b/crates/geometry/src/prelude.rs index dec1bb2..7ee9da7 100644 --- a/crates/geometry/src/prelude.rs +++ b/crates/geometry/src/prelude.rs @@ -51,9 +51,11 @@ pub use crate::cs::{Cartesian, CoordinateSystem, Degree, Geographic, Radian, Sph pub use crate::model::{Point2D, Point3D}; pub use crate::overlay::{ De9im, Dimension, JoinStrategy, OverlayError, PointStrategy, RelateError, ValidityFailure, - buffer, buffer_convex_polygon, buffer_point, buffer_with, crosses, difference, intersection, - is_valid, is_valid_polygon, is_valid_ring, merge_elements, merge_multipolygon, merge_polygons, + ValidityOptions, buffer, buffer_convex_polygon, buffer_point, buffer_with, crosses, difference, + intersection, is_valid, is_valid_polygon, is_valid_polygon_with, is_valid_ring, + is_valid_ring_with, is_valid_with, merge_elements, merge_multipolygon, merge_polygons, overlaps, point_on_surface, relate, relation, sym_difference, touches, r#union, union_poly, + validity_reason, validity_reason_with, }; pub use crate::rtree::{ Bounds, Indexable, Linear, Predicate, Quadratic, QueryPredicate, Rtree, and, not, satisfies, diff --git a/crates/geometry/tests/policy_parity.rs b/crates/geometry/tests/policy_parity.rs new file mode 100644 index 0000000..4819513 --- /dev/null +++ b/crates/geometry/tests/policy_parity.rs @@ -0,0 +1,257 @@ +//! Public-facade parity tests for Boost.Geometry policy headers. + +use core::cmp::Ordering; + +use boost_geometry::model::{Point2D, Polygon, Ring}; +use boost_geometry::prelude::{ + Cartesian, Degree, Geographic, Radian, Spherical, ValidityFailure, ValidityOptions, is_valid, + is_valid_with, validity_reason, validity_reason_with, +}; +use boost_geometry::strategy::compare::{EqualTo, Greater, Less, LessExact}; + +type CartesianPoint = Point2D; +const LESS: Less = Less; +const LESS_EXACT: LessExact = LessExact; +const GREATER: Greater = Greater; +const EQUAL_TO: EqualTo = EqualTo; + +/// `test/policies/compare.cpp:48-132` — the default policy compares all +/// coordinates lexicographically; dimension policies inspect one ordinate. +#[test] +fn cartesian_compare_matches_the_reference_matrix() { + let p1 = CartesianPoint::new(3.0, 1.0); + let p2 = CartesianPoint::new(3.0, 1.0); + let p3 = CartesianPoint::new(1.0, 3.0); + let p4 = CartesianPoint::new(5.0, 2.0); + let p5 = CartesianPoint::new(3.0, 2.0); + + assert!(EQUAL_TO.apply(&p1, &p2)); + assert!(!EQUAL_TO.apply(&p1, &p3)); + assert!(LESS.apply(&p1, &p4)); + assert!(LESS.apply(&p1, &p5)); + assert!(LESS.apply(&p3, &p4)); + assert!(GREATER.apply(&p1, &p3)); + + assert!(EqualTo::<0>.apply(&p1, &p5)); + assert!(!Less::<0>.apply(&p1, &p5)); + assert!(Greater::<0>.apply(&p1, &p3)); + + assert!(!EqualTo::<1>.apply(&p1, &p5)); + assert!(Less::<1>.apply(&p1, &p3)); + assert!(Less::<1>.apply(&p1, &p5)); + assert!(Greater::<1>.apply(&p3, &p4)); +} + +/// `test/policies/compare.cpp:135-201` — the policies are suitable for +/// ascending, descending, and single-dimension sorting. +#[test] +fn cartesian_compare_sorts_like_the_reference_policy() { + let mut points = [ + CartesianPoint::new(3.0, 1.0), + CartesianPoint::new(2.0, 3.0), + CartesianPoint::new(2.0, 2.0), + CartesianPoint::new(1.0, 3.0), + ]; + + points.sort_by(|left, right| { + if LESS.apply(left, right) { + Ordering::Less + } else if GREATER.apply(left, right) { + Ordering::Greater + } else { + Ordering::Equal + } + }); + assert_eq!( + points, + [ + CartesianPoint::new(1.0, 3.0), + CartesianPoint::new(2.0, 2.0), + CartesianPoint::new(2.0, 3.0), + CartesianPoint::new(3.0, 1.0), + ] + ); + + points.sort_by(|left, right| { + if GREATER.apply(left, right) { + Ordering::Less + } else if LESS.apply(left, right) { + Ordering::Greater + } else { + Ordering::Equal + } + }); + assert_eq!(points[0], CartesianPoint::new(3.0, 1.0)); + + points.sort_by(|left, right| { + if Less::<1>.apply(left, right) { + Ordering::Less + } else if Greater::<1>.apply(left, right) { + Ordering::Greater + } else { + Ordering::Equal + } + }); + assert_eq!(points[0], CartesianPoint::new(3.0, 1.0)); +} + +/// `policies/compare.hpp:35-73` — ordinary comparison treats values within +/// Boost's epsilon as equal while `less_exact` does not. +#[test] +fn exact_and_epsilon_less_policies_are_distinct() { + let left = CartesianPoint::new(1.0, 0.0); + let right = CartesianPoint::new(1.0 + f64::EPSILON, 0.0); + + assert!(EQUAL_TO.apply(&left, &right)); + assert!(!LESS.apply(&left, &right)); + assert!(LESS_EXACT.apply(&left, &right)); +} + +/// `test/policies/compare.cpp:241-250` — integer and floating coordinate +/// models use the same public policy, including mixed-scalar comparisons. +#[test] +fn cartesian_compare_accepts_integer_and_mixed_scalars() { + let integer = Point2D::::new(3, 1); + let wider_integer = Point2D::::new(3, 2); + let floating = CartesianPoint::new(4.0, 0.0); + + assert!(LESS.apply(&integer, &wider_integer)); + assert!(LESS.apply(&integer, &floating)); + assert!(GREATER.apply(&floating, &wider_integer)); +} + +/// `test/policies/compare.cpp:204-238` and +/// `strategies/spherical/compare.hpp:96-164` — the antimeridian sorts after +/// ordinary longitudes, its two spellings compare equal on longitude, and +/// degree/radian inputs compare in a shared unit. +#[test] +fn spherical_and_geographic_compare_handle_angular_coordinates() { + type SphericalPoint = Point2D>; + let mut points = [ + SphericalPoint::new(180.0, 70.56), + SphericalPoint::new(179.73, 71.56), + SphericalPoint::new(177.47, 71.23), + SphericalPoint::new(-178.78, 72.78), + SphericalPoint::new(-180.0, 73.12), + ]; + points.sort_by(|left, right| { + if LESS.apply(left, right) { + Ordering::Less + } else if GREATER.apply(left, right) { + Ordering::Greater + } else { + Ordering::Equal + } + }); + assert_eq!((points[0].x(), points[0].y()), (-178.78, 72.78)); + assert_eq!((points[3].x(), points[3].y()), (180.0, 70.56)); + assert_eq!((points[4].x(), points[4].y()), (-180.0, 73.12)); + assert!(EqualTo::<0>.apply( + &SphericalPoint::new(180.0, 0.0), + &SphericalPoint::new(-180.0, 10.0), + )); + + let degrees = Point2D::>::new(180.0, 45.0); + let radians = Point2D::>::new( + core::f64::consts::PI, + core::f64::consts::FRAC_PI_4, + ); + assert!(EQUAL_TO.apply(°rees, &radians)); +} + +fn duplicate_polygon() -> Polygon { + Polygon::new(Ring::from_vec(vec![ + CartesianPoint::new(0.0, 0.0), + CartesianPoint::new(0.0, 2.0), + CartesianPoint::new(0.0, 2.0), + CartesianPoint::new(2.0, 2.0), + CartesianPoint::new(2.0, 0.0), + CartesianPoint::new(0.0, 0.0), + ])) +} + +/// `test/algorithms/is_valid_failure.cpp` and +/// `policies/is_valid/failing_reason_policy.hpp:32-63` — every public result +/// has the stable base reason used by Boost's reason policy. +#[test] +fn validity_failures_expose_reference_reason_messages() { + let reference_reasons = [ + (ValidityFailure::FewPoints, "Geometry has too few points"), + ( + ValidityFailure::WrongTopologicalDimension, + "Geometry has wrong topological dimension", + ), + (ValidityFailure::Spikes, "Geometry has spikes"), + ( + ValidityFailure::DuplicatePoints, + "Geometry has duplicate (consecutive) points", + ), + ( + ValidityFailure::NotClosed, + "Geometry is defined as closed but is open", + ), + ( + ValidityFailure::SelfIntersection, + "Geometry has invalid self-intersections", + ), + ( + ValidityFailure::WrongOrientation, + "Geometry has wrong orientation", + ), + ( + ValidityFailure::InteriorRingOutside, + "Geometry has interior rings defined outside the outer boundary", + ), + ( + ValidityFailure::NestedInteriorRings, + "Geometry has nested interior rings", + ), + ( + ValidityFailure::DisconnectedInterior, + "Geometry has disconnected interior", + ), + ( + ValidityFailure::IntersectingInteriors, + "Multi-polygon has intersecting interiors", + ), + ( + ValidityFailure::WrongCornerOrder, + "Box has corners in wrong order", + ), + ( + ValidityFailure::InvalidCoordinate, + "Geometry has point(s) with invalid coordinate(s)", + ), + ]; + for (failure, reason) in reference_reasons { + assert_eq!(failure.message(), reason); + assert_eq!(failure.to_string(), reason); + } + + let valid: Polygon = Polygon::new(Ring::from_vec(vec![ + CartesianPoint::new(0.0, 0.0), + CartesianPoint::new(0.0, 2.0), + CartesianPoint::new(2.0, 2.0), + CartesianPoint::new(2.0, 0.0), + CartesianPoint::new(0.0, 0.0), + ])); + assert_eq!(validity_reason(&valid), "Geometry is valid"); + assert_eq!( + validity_reason(&duplicate_polygon()), + "Geometry has duplicate (consecutive) points" + ); +} + +/// `policies/is_valid/default_policy.hpp:26-61` — Boost's default allows +/// consecutive duplicates. The existing strict Rust default stays intact, +/// and the Boost behavior is selected explicitly through the public facade. +#[test] +fn validity_options_preserve_strict_behavior_and_offer_boost_defaults() { + let duplicate = duplicate_polygon(); + assert_eq!(is_valid(&duplicate), Err(ValidityFailure::DuplicatePoints)); + assert!(is_valid_with(&duplicate, ValidityOptions::BOOST_DEFAULT).is_ok()); + assert_eq!( + validity_reason_with(&duplicate, ValidityOptions::BOOST_DEFAULT), + "Geometry is valid" + ); +} diff --git a/docs/crates/geometry-overlay.md b/docs/crates/geometry-overlay.md index 9d5dda2..2372a4f 100644 --- a/docs/crates/geometry-overlay.md +++ b/docs/crates/geometry-overlay.md @@ -23,7 +23,7 @@ functions (`intersection`, `union`, `difference`, `sym_difference`), plus | `assemble` | OVL4 | Nest traversed rings into `Polygon`/`MultiPolygon` by containment | | `operation` (+ `boolean`, `areal`) | OVL5 | split-edge arrangement; `intersection`, `r#union` (`union_poly` compatibility name), `difference`, `sym_difference`, `OverlayError` | | `relate` | OVL6 | Cartesian static/multi/runtime/collection `relation` matrix, `relate` mask, `De9im`, `crosses`/`overlaps`/`touches`, `Dimension` | -| `validity` | OVL6 | ring/polygon/multi-polygon `is_valid`, inter-ring/member topology, `ValidityFailure` | +| `validity` | OVL6 | ring/polygon/multi-polygon `is_valid`/`is_valid_with`, inter-ring/member topology, complete `ValidityFailure` taxonomy, `ValidityOptions`, allocation-free reason messages | | `surface_point` | — | `point_on_surface` — a representative interior point, used by `assemble` and `relate` | | `buffer` | OVL7 | Cartesian/spherical/geographic single/multi dispatch, `buffer`/`buffer_with`/`buffer_with_strategy`, signed areal offsets, linear ends, strategy bundles | | `merge` | — | `merge_elements`, `merge_polygons`, `merge_multipolygon` | diff --git a/docs/crates/geometry-strategy.md b/docs/crates/geometry-strategy.md index 88d57d8..830fffa 100644 --- a/docs/crates/geometry-strategy.md +++ b/docs/crates/geometry-strategy.md @@ -47,6 +47,7 @@ kind and coordinate-system family — combine). | `line_interpolate` | `LineInterpolateStrategy` | `CartesianLineInterpolate` | | `transform` | `TransformStrategy` | `Affine2`, `Affine3` | | `closest_points` | `ClosestPointsStrategy` | `CartesianClosestPoints` | +| `compare` | point comparison policy dispatch by coordinate-system family | `Less`, `LessExact`, `Greater`, `EqualTo` (all shared dimensions or one const-selected dimension; Cartesian/spherical/geographic) | `cartesian`, `spherical`, `geographic` are the per-family submodules that hold the concrete impls above. The geographic module also exposes Thomas, @@ -54,6 +55,13 @@ Vincenty, and Karney direct/inverse formulas, differential quantities, meridian/vertex formulas, and Gnomonic/Sjöberg intersection. `normalise` is `pub(crate)` (angular normalization machinery, not part of the public surface). +The `compare` policies mirror `boost/geometry/policies/compare.hpp`. Cartesian +points use lexicographic coordinate order. Spherical and geographic points +add Boost's antimeridian/pole ordering and normalize differing angular units +before comparison. These policies are distinct from +`geometry_coords::Comparable`, which is the squared-distance wrapper described +above. + ## Reverse dispatch For algorithms whose two arguments are symmetric, write one impl per tag