diff --git a/iOverlay/.gitignore b/iOverlay/.gitignore new file mode 100644 index 0000000..3412b31 --- /dev/null +++ b/iOverlay/.gitignore @@ -0,0 +1 @@ +/AGENTS.md diff --git a/iOverlay/README.md b/iOverlay/README.md index 783fdde..0465743 100644 --- a/iOverlay/README.md +++ b/iOverlay/README.md @@ -37,6 +37,7 @@ For specialized geometry, see [iCurve](https://github.com/iShape-Rust/iCurve) fo - [Offsetting a Polygon](#offsetting-a-polygon) - [LineCap](#linecap) - [LineJoin](#linejoin) +- [Integer Coordinate Limits](#integer-coordinate-limits) - [FAQ](#faq) - [License](#license) @@ -583,6 +584,25 @@ println!("shapes: {:?}", &shapes);   +## Integer Coordinate Limits + +For an `N`-bit engine, keep each input coordinate within +`-2^(N - 2)..=2^(N - 2) - 1` (inclusive): + +| Engine | Minimum x or y | Maximum x or y | +| --- | ---: | ---: | +| `i16` | -16,384 | 16,383 | +| `i32` | -1,073,741,824 | 1,073,741,823 | +| `i64` | -4,611,686,018,427,387,904 | 4,611,686,018,427,387,903 | + +These limits leave room for coordinate differences and their products. They apply +to all inputs and solver strategies. Integer APIs do not check them; exceeding +these bounds can cause overflow or incorrect results. Use a wider engine or rescale +larger inputs. See the [range derivation and arithmetic audit](readme/integer_range.md) for details. + +For float APIs, the limits apply after conversion. An explicit conservative budget +is `FloatPointAdapter::with_coordinate_bits(rect, I::BITS - 3)`. + ## FAQ ### 1. When should I use `FloatOverlay`, `SingleFloatOverlay`, or `FloatOverlayGraph`? diff --git a/iOverlay/readme/integer_range.md b/iOverlay/readme/integer_range.md new file mode 100644 index 0000000..acb9c62 --- /dev/null +++ b/iOverlay/readme/integer_range.md @@ -0,0 +1,70 @@ +# Integer coordinate range audit + +The coordinate interval is `-2^(N - 2)..=2^(N - 2) - 1` for an `N`-bit +engine. Both endpoints are included. Let `D = 2^(N - 1) - 1 = I::MAX`. +Every coordinate difference then has magnitude at most `D`. + +This is a bound on coordinate arithmetic for polygon, string, predicate, and +vector overlays. It does not bound allocation sizes, winding counts, or +arithmetic in user-provided edge-data callbacks. Floating-point inputs must +respect the interval **after** conversion; custom scales are the caller's +responsibility. + +## Point and vector operations + +Point subtraction widens to `I::Wide`. Each product of differences has magnitude +at most `D^2`; a dot product, cross product, or squared distance is bounded by +`2 * D^2`. For all three engines: + +```text +2 * D^2 = 2^(2*N - 1) - 2^(N + 1) + 2 < 2^(2*N - 1) +``` + +Thus these expressions, including negation of cross products, fit in the signed +wide type (`i32`, `i64`, or `i128`). The same calculation covers input collinearity +filtering, segment ordering, hole binding, point queries, and snapping distances. + +## Intersection coordinates + +`CrossSolver::cross_point` translates endpoints relative to one endpoint. The +translated coordinates and the other segment's direction still have magnitude +at most `D`: subtracting two translated coordinates cancels the common offset. +The determinants `xy_b` and `div` are bounded by `2 * D^2`. + +The general intersection numerator multiplies a determinant by a direction +component. It uses `UIntProduct`, with **4*N bits**, rather than `I::Wide`: +its magnitude is at most `2 * D^3 < 2^(3*N - 2)`. The unsigned divisor is +positive and below `2^(2*N - 1)`, satisfying `divide_with_rounding`'s precondition. +The quotient is an intersection displacement and has magnitude at most `D`. + +A true segment intersection is within both segments' coordinate bounds. +Rounding or truncating a coordinate relative to an integer endpoint cannot +move it outside those integer bounds. Snapping selects an existing endpoint. +Consequently, every repair iteration preserves the global input coordinate box. + +## Area accumulation + +Twice the final area of an output contour is at most `2 * D^2`. A partial +shoelace sum has no such bound: a simple spiral can wind around the origin many +times before the return path cancels most of the accumulated area. + +Both ordinary and vector contours therefore accumulate area with wrapping +arithmetic. This computes the exact sum modulo `2^(2*N)`; since the final signed +area fits, its final representation is exact. Reducing the coordinate range by +a fixed number of bits would not solve arbitrary partial-sum overflow. + +## Boundary coverage + +- All engines and solver strategies: inclusive endpoints, one-unit features, + holes, steep edges, collinear overlaps, exact and rounded intersections. +- A simple spiral: vector area filtering at its exact area and one unit above. +- Growing and oversized snapping thresholds, including shift and addition limits. +- Boundary intersection coordinates checked against an independent `i128` + rational calculation, including `i64` intersections using extended products. +- Fragment enclosures checked against exact rational segment heights for steep, + shallow, increasing, and decreasing edges at several grid resolutions. +- Seeded `i16` and `i32` overlays compared with the same input in the `i64` engine. + +Adding one more unit to the interval allows a positive difference `D + 1` +that does not fit in `I`, and `2 * (D + 1)^2` does not fit in `I::Wide`. +Larger intervals therefore need a different arithmetic contract. diff --git a/iOverlay/src/core/edge_overlay.rs b/iOverlay/src/core/edge_overlay.rs index 5b6da03..2d436b8 100644 --- a/iOverlay/src/core/edge_overlay.rs +++ b/iOverlay/src/core/edge_overlay.rs @@ -22,6 +22,10 @@ pub struct InputEdge { pub data: D, } +/// Integer overlay builder with per-edge data. +/// +/// Edge endpoints must satisfy the [integer coordinate range](crate::core::integer). +/// Bounds are not checked. pub struct EdgeOverlay { pub solver: Solver, pub options: IntOverlayOptions, diff --git a/iOverlay/src/core/integer.rs b/iOverlay/src/core/integer.rs index 55702e2..4402770 100644 --- a/iOverlay/src/core/integer.rs +++ b/iOverlay/src/core/integer.rs @@ -1,4 +1,30 @@ //! Integer types supported by the overlay engine. +//! +//! # Coordinate range +//! +//! For an `N`-bit engine, keep **every x and y coordinate**, across all inputs to +//! an operation, in the inclusive range `-2^(N - 2)..=2^(N - 2) - 1`: +//! +//! | Engine | Minimum | Maximum | +//! | --- | ---: | ---: | +//! | `i16` | -16,384 | 16,383 | +//! | `i32` | -1,073,741,824 | 1,073,741,823 | +//! | `i64` | -4,611,686,018,427,387,904 | 4,611,686,018,427,387,903 | +//! +//! The maximum coordinate difference is `D = I::MAX`; sums of two products +//! fit in `I::Wide` because `2 * D^2 < 2^(2*N - 1)`. Intersection numerators +//! use extended-width products. Area accumulation must allow partial sums to +//! wrap even though the final contour area fits. Integer input bounds are not +//! checked at runtime. +//! +//! ## Floating-point conversion +//! +//! These limits concern the integer coordinates after conversion, not the +//! original floating-point coordinates. For an explicit conservative bound, +//! use [`FloatPointAdapter::with_coordinate_bits`](i_float::adapter::FloatPointAdapter::with_coordinate_bits) +//! with `coordinate_bits = I::BITS - 3`. This bounds the converted magnitude by +//! `2^(N - 3)` (8,192 for `i16`), with both endpoints included. A custom unchecked +//! scale must respect the integer range too. use i_float::int::number::int::IntNumber; use i_key_sort::sort::key::SortKey; @@ -18,6 +44,7 @@ mod private { /// /// This trait is sealed. The supported integer engines are [`i16`], [`i32`], /// and [`i64`]. +/// See the [coordinate range](self) required by the integer APIs. pub trait OverlayInt: private::OverlayIntSealed {} impl OverlayInt for i16 {} diff --git a/iOverlay/src/core/overlay.rs b/iOverlay/src/core/overlay.rs index 98fa06a..d959630 100644 --- a/iOverlay/src/core/overlay.rs +++ b/iOverlay/src/core/overlay.rs @@ -64,6 +64,9 @@ pub enum ContourDirection { } /// This struct is essential for describing and uploading the geometry or shapes required to construct an `OverlayGraph`. It prepares the necessary data for boolean operations. +/// +/// All input coordinates must satisfy the [integer coordinate range](crate::core::integer). +/// Bounds are not checked when adding geometry. pub struct Overlay { pub solver: Solver, pub options: IntOverlayOptions, diff --git a/iOverlay/src/core/relate.rs b/iOverlay/src/core/relate.rs index 3caf0d1..3ba65ac 100644 --- a/iOverlay/src/core/relate.rs +++ b/iOverlay/src/core/relate.rs @@ -16,6 +16,9 @@ use i_shape::int::shape::{IntContour, IntShape}; /// Overlay structure optimized for spatial predicate evaluation. /// +/// All input coordinates must satisfy the [integer coordinate range](crate::core::integer). +/// Bounds are not checked when adding geometry. +/// /// `PredicateOverlay` provides efficient spatial relationship testing between /// two polygon sets without computing full boolean operation results. It is /// designed for cases where you only need to know *whether* shapes intersect, diff --git a/iOverlay/src/core/solver.rs b/iOverlay/src/core/solver.rs index 679fe1b..a2a5788 100644 --- a/iOverlay/src/core/solver.rs +++ b/iOverlay/src/core/solver.rs @@ -21,16 +21,20 @@ pub enum Strategy { /// Represents the precision level used by the solver to determine /// the tolerance for snapping to the nearest edge ends. /// -/// The precision determines a radius calculated as `2^value`, +/// The precision determines a squared radius calculated as `2^value`, /// where `value` starts at `start` and increases in increments /// defined by `progression` in each iteration. +/// The exponent is capped at `2 * (I::BITS - 4)` for the selected integer engine, +/// limiting the linear radius to `2^(I::BITS - 4)`. +/// This threshold is compared directly with squared distances; the corresponding +/// linear radius is `sqrt(2^value)`. /// /// - `start`: The initial exponent value. /// - `progression`: The step size for incrementing the exponent /// in each iteration. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Precision { - /// The initial exponent value for the radius calculation. + /// The initial exponent value for the squared-radius calculation. pub start: usize, /// The amount by which the exponent increases in each iteration. pub progression: usize, @@ -38,37 +42,37 @@ pub struct Precision { impl Precision { /// Absolute precision with no progression. - /// (Radius remains at `2^0 = 1`) + /// (Squared radius remains at `2^0 = 1`) pub const ABSOLUTE: Precision = Self { start: 0, progression: 0, }; - /// High precision, starting at `2^0 = 1` and doubling every loop. + /// High precision, with squared radius starting at `2^0 = 1` and doubling every loop. pub const HIGH: Precision = Self { start: 0, progression: 1, }; - /// Medium-high precision, starting at `2^1 = 2` and doubling every loop. + /// Medium-high precision, with squared radius starting at `2^1 = 2` and doubling every loop. pub const MEDIUM_HIGH: Precision = Self { start: 1, progression: 1, }; - /// Medium precision, starting at `2^0 = 1` and quadrupling every loop. + /// Medium precision, with squared radius starting at `2^0 = 1` and quadrupling every loop. pub const MEDIUM: Precision = Self { start: 0, progression: 2, }; - /// Medium-low precision, starting at `2^2 = 4` and quadrupling every loop. + /// Medium-low precision, with squared radius starting at `2^2 = 4` and quadrupling every loop. pub const MEDIUM_LOW: Precision = Self { start: 2, progression: 2, }; - /// Low precision, starting at `2^2 = 4` and increasing by a factor of 8 every loop. + /// Low precision, with squared radius starting at `2^2 = 4` and increasing by a factor of 8 every loop. pub const LOW: Precision = Self { start: 2, progression: 3, diff --git a/iOverlay/src/lib.rs b/iOverlay/src/lib.rs index dd7cc88..6db3f92 100644 --- a/iOverlay/src/lib.rs +++ b/iOverlay/src/lib.rs @@ -10,6 +10,12 @@ //! - **Fill Rules**: even-odd, non-zero, positive and negative. //! - **Data Types**: Supports `i16`/`i32`/`i64` integer APIs and `f32`/`f64` floating-point APIs. //! +//! ## Integer coordinate limits +//! +//! Integer inputs must stay within `-2^(N - 2)..=2^(N - 2) - 1` for an `N`-bit +//! engine; for example, `-16_384..=16_383` for `i16`. The full storage-type range +//! is not supported. See [coordinate ranges and their derivation](core::integer). +//! //! ## Simple Example //! ![Simple Example](https://raw.githubusercontent.com/iShape-Rust/iOverlay/main/readme/example_union.svg) //! Here's an example of performing a union operation between two polygons: diff --git a/iOverlay/src/split/cross_solver.rs b/iOverlay/src/split/cross_solver.rs index c93cffc..c33938f 100644 --- a/iOverlay/src/split/cross_solver.rs +++ b/iOverlay/src/split/cross_solver.rs @@ -76,7 +76,7 @@ impl CrossSolver { pub(super) fn cross( target: &XSegment, other: &XSegment, - radius: I::Wide, + radius_squared: I::Wide, ) -> Option> { let a0b0a1 = Triangle::clock_direction(target.a, target.b, other.a); let a0b0b1 = Triangle::clock_direction(target.a, target.b, other.b); @@ -131,7 +131,7 @@ impl CrossSolver { }; } - Self::middle_cross(target, other, radius) + Self::middle_cross(target, other, radius_squared) } pub(super) fn collinear(target: &XSegment, other: &XSegment) -> CollinearMask { @@ -161,7 +161,11 @@ impl CrossSolver { CollinearMask::new(is_target_a, is_target_b, is_other_a, is_other_b) } - fn middle_cross(target: &XSegment, other: &XSegment, radius: I::Wide) -> Option> { + fn middle_cross( + target: &XSegment, + other: &XSegment, + radius_squared: I::Wide, + ) -> Option> { let p = CrossSolver::cross_point(target, other); if Triangle::is_line(target.a, p, target.b) && Triangle::is_line(other.a, p, other.b) { @@ -172,7 +176,7 @@ impl CrossSolver { }); } - // still can be common ends because of rounding + // Rounding can bring the crossing close to an endpoint. // snap to nearest end with r (1^2 + 1^2 == 2) let ra0 = target.a.sqr_distance(p); @@ -181,7 +185,7 @@ impl CrossSolver { let ra1 = other.a.sqr_distance(p); let rb1 = other.b.sqr_distance(p); - if ra0 <= radius || ra1 <= radius || rb0 <= radius || rb1 <= radius { + if ra0 <= radius_squared || ra1 <= radius_squared || rb0 <= radius_squared || rb1 <= radius_squared { let r0 = ra0.min(rb0); let r1 = ra1.min(rb1); @@ -218,8 +222,10 @@ impl CrossSolver { fn cross_point(target: &XSegment, other: &XSegment) -> IntPoint { // edges are not parallel - // any abs(x) and abs(y) < 2^30 - // The result must be < 2^30 + // Input coordinates follow the N-bit range documented in core::integer. + // Coordinate differences are at most I::MAX in magnitude; sums and + // differences of two products fit in I::Wide. The intersection remains + // within the segment bounds, including after integer rounding. // Classic approach: @@ -317,6 +323,81 @@ mod tests { use crate::split::cross_solver::{CrossSolver, CrossType}; use i_float::int::point::IntPoint; + #[test] + fn snapping_compares_squared_distance_directly_with_the_threshold() { + let target = XSegment::new(IntPoint::new(0, 0), IntPoint::new(5, 5)); + let other = XSegment::new(IntPoint::new(0, 3), IntPoint::new(5, -2)); + // The rounded crossing is (2, 2). Its nearest endpoint is (0, 3), + // at squared distance 5. A threshold of 4 must not be squared again. + let crossing = CrossSolver::cross(&target, &other, 4).unwrap(); + assert!(matches!(crossing.cross_type, CrossType::Pure)); + assert_eq!(crossing.point, IntPoint::new(2, 2)); + let snapped = CrossSolver::cross(&target, &other, 5).unwrap(); + assert!(matches!(snapped.cross_type, CrossType::OtherEnd)); + assert_eq!(snapped.point, other.a); + } + + #[test] + fn boundary_crossings_match_exact_rational_coordinates() { + use crate::core::integer::OverlayInt; + + fn check + Into>() { + let hi = (1_i64 << (I::BITS - 2)) - 1; + let lo = -hi - 1; + let ys = [lo, lo + 1, lo + 2, -1, 0, 1, hi - 1, hi]; + let point = |x, y| IntPoint::new(I::try_from(x).ok().unwrap(), I::try_from(y).ok().unwrap()); + for a0 in ys { + for a1 in ys { + for b0 in ys { + for b1 in ys { + // Strictly intersecting segments with the same x interval. + // Their intersection parameter is t = n / d. Unlike the + // general determinant formula, this oracle fits in i128 + // even for i64 coordinates and needs no UIntProduct. + let n = b0 as i128 - a0 as i128; + let d = (a1 as i128 - a0 as i128) - (b1 as i128 - b0 as i128); + if n == 0 || d == 0 || n.signum() != d.signum() || n.abs() >= d.abs() { + continue; + } + let round = |delta: i128| { + let product = delta * n.abs(); + let q = (product.abs() + d.abs() / 2) / d.abs(); + q * product.signum() + }; + let expected_x = lo as i128 + round(hi as i128 - lo as i128); + // Axis-aligned branches truncate relative to the first endpoint. + let expected_y = a0 as i128 + round(a1 as i128 - a0 as i128); + let a = XSegment { + a: point(lo, a0), + b: point(hi, a1), + }; + let b = XSegment { + a: point(lo, b0), + b: point(hi, b1), + }; + let result = CrossSolver::::cross_point(&a, &b); + // For a horizontal target x is truncated, not rounded. + let expected_x = if a0 == a1 { + lo as i128 + (hi as i128 - lo as i128) * n.abs() / d.abs() + } else { + expected_x + }; + assert_eq!( + [result.x.into(), result.y.into()], + [expected_x as i64, expected_y as i64], + "{}: {a0}, {a1}, {b0}, {b1}", + core::any::type_name::() + ); + } + } + } + } + } + check::(); + check::(); + check::(); + } + impl XSegment { fn new(a: IntPoint, b: IntPoint) -> Self { Self { a, b } diff --git a/iOverlay/src/split/grid_layout.rs b/iOverlay/src/split/grid_layout.rs index 2a90eaa..01813f1 100644 --- a/iOverlay/src/split/grid_layout.rs +++ b/iOverlay/src/split/grid_layout.rs @@ -262,7 +262,8 @@ impl GridLayout { #[inline] pub(super) fn pos(&self, index: usize) -> I { - I::from_usize(index << self.power) + self.min_x + // Convert before shifting: the offset fits I, but may exceed usize on 32-bit targets. + (I::from_usize(index) << self.power) + self.min_x } pub(super) fn new(iter: It, count: usize) -> Option @@ -308,6 +309,79 @@ mod tests { use i_float::triangle::Triangle; use rand::RngExt; + #[test] + fn i64_column_positions_with_large_shift() { + let layout = GridLayout { + min_x: -(1_i64 << 62), + max_x: (1_i64 << 62) - 1, + power: 60, + }; + // A shift in usize would fail on a 32-bit target. + assert_eq!(layout.pos(0), layout.min_x); + assert_eq!(layout.pos(4), 0); + assert_eq!(layout.pos(7), 3_i64 << 60); + } + + #[test] + fn boundary_fragments_enclose_exact_segment() { + use crate::core::integer::OverlayInt; + + fn check + Into>() { + let lo = -(1_i64 << (I::BITS - 2)); + let hi = -lo - 1; + let span = hi - lo; + let lengths = [1, 2, 3, 4, 5, 127, 128, 129, span / 2, span - 1, span]; + let int = |v| I::try_from(v).ok().unwrap(); + for width in lengths { + for height in lengths { + for right in [false, true] { + for descending in [false, true] { + let x0 = if right { hi - width } else { lo }; + let x1 = x0 + width; + let (y0, y1) = if descending { + (hi, hi - height) + } else { + (lo, lo + height) + }; + let segment = XSegment { + a: IntPoint::new(int(x0), int(y0)), + b: IntPoint::new(int(x1), int(y1)), + }; + for max_power in [1, 3, 6] { + let Some(layout) = GridLayout::with_min_max(int(x0), int(x1), max_power) + else { + continue; + }; + let mut buffer = FragmentBuffer::new(layout); + buffer.add_segment(0, segment); + let mut previous_x = x0; + for f in buffer.groups.iter().flatten() { + let r = &f.rect; + assert_eq!(r.min_x.into(), previous_x); + previous_x = r.max_x.into(); + assert!(r.min_x <= r.max_x && r.min_y <= r.max_y); + assert!(r.min_y.into() >= y0.min(y1) && r.max_y.into() <= y0.max(y1)); + // Exact rational y at both ends of the fragment, using + // i128 independently of the fixed-point approximation. + for x in [r.min_x.into(), r.max_x.into()] { + let y_numerator = y0 as i128 * width as i128 + + (y1 as i128 - y0 as i128) * (x as i128 - x0 as i128); + assert!((r.min_y.into() as i128) * width as i128 <= y_numerator); + assert!(y_numerator <= (r.max_y.into() as i128) * width as i128); + } + } + assert_eq!(previous_x, x1); + } + } + } + } + } + } + check::(); + check::(); + check::(); + } + #[test] fn test_0() { let layout = GridLayout { diff --git a/iOverlay/src/split/snap_radius.rs b/iOverlay/src/split/snap_radius.rs index 08eb92a..32540dd 100644 --- a/iOverlay/src/split/snap_radius.rs +++ b/iOverlay/src/split/snap_radius.rs @@ -9,11 +9,13 @@ pub(super) struct SnapRadius { impl SnapRadius { pub(super) fn increment(&mut self) { - self.current = 60.min(self.current + self.step); + self.current = self.current.saturating_add(self.step); } - pub(super) fn radius(&self) -> I::Wide { - I::Wide::ONE << self.current as u32 + /// Squared-distance threshold for snapping to an existing endpoint. + pub(super) fn radius_squared(&self) -> I::Wide { + let exponent = self.current.min((2 * (I::BITS - 4)) as usize) as u32; + I::Wide::ONE << exponent } } @@ -25,3 +27,43 @@ impl Solver { } } } + +#[cfg(test)] +mod tests { + use super::SnapRadius; + + #[test] + fn squared_radius_preserves_initial_progression() { + let mut snap = SnapRadius { current: 0, step: 1 }; + for expected in [1, 2, 4, 8] { + assert_eq!(snap.radius_squared::(), expected); + snap.increment(); + } + } + + #[test] + fn squared_radius_saturates_at_each_engine_limit() { + macro_rules! check_limit { + ($int:ty, $exponent:expr) => {{ + let mut snap = SnapRadius { + current: $exponent - 1, + step: 1, + }; + let limit: <$int as i_float::int::number::int::IntNumber>::Wide = 1 << $exponent; + assert_eq!(snap.radius_squared::<$int>(), limit / 2); + snap.increment(); + assert_eq!(snap.radius_squared::<$int>(), limit); + snap.increment(); + assert_eq!(snap.radius_squared::<$int>(), limit); + snap.step = usize::MAX; + snap.increment(); + assert_eq!(snap.radius_squared::<$int>(), limit); + snap.increment(); + assert_eq!(snap.radius_squared::<$int>(), limit); + }}; + } + check_limit!(i16, 24); + check_limit!(i32, 56); + check_limit!(i64, 120); + } +} diff --git a/iOverlay/src/split/solver.rs b/iOverlay/src/split/solver.rs index fc8e23e..afdffe6 100644 --- a/iOverlay/src/split/solver.rs +++ b/iOverlay/src/split/solver.rs @@ -85,9 +85,9 @@ where ei: &XSegment, ej: &XSegment, marks: &mut Vec>, - radius: I::Wide, + radius_squared: I::Wide, ) -> bool { - let cross = if let Some(cross) = CrossSolver::::cross(ei, ej, radius) { + let cross = if let Some(cross) = CrossSolver::::cross(ei, ej, radius_squared) { cross } else { return false; diff --git a/iOverlay/src/split/solver_fragment.rs b/iOverlay/src/split/solver_fragment.rs index 9b622c4..95eebdd 100644 --- a/iOverlay/src/split/solver_fragment.rs +++ b/iOverlay/src/split/solver_fragment.rs @@ -3,7 +3,6 @@ use crate::core::integer::OverlayInt; use crate::core::solver::Solver; use crate::segm::segment::Segment; use crate::segm::winding::WindingCount; -use crate::split::cross_solver::{CrossSolver, CrossType, EndMask}; use crate::split::fragment::Fragment; use crate::split::grid_layout::{BorderVSegment, FragmentBuffer, GridLayout}; use crate::split::line_mark::LineMark; @@ -45,7 +44,7 @@ where buffer.add_segment(i, segment.x_segment); } - need_to_fix = self.process(snap_radius.radius::(), &mut buffer, solver); + need_to_fix = self.process(snap_radius.radius_squared::(), &mut buffer, solver); #[cfg(debug_assertions)] debug_assert!(buffer.is_on_border_sorted()); @@ -82,32 +81,32 @@ where } #[inline] - fn process(&mut self, radius: I::Wide, buffer: &mut FragmentBuffer, _solver: &Solver) -> bool { + fn process(&mut self, radius_squared: I::Wide, buffer: &mut FragmentBuffer, _solver: &Solver) -> bool { #[cfg(feature = "allow_multithreading")] { if _solver.multithreading.is_some() { - return self.parallel_split(radius, buffer); + return self.parallel_split(radius_squared, buffer); } } - self.serial_split(radius, buffer) + self.serial_split(radius_squared, buffer) } #[inline] - fn serial_split(&mut self, radius: I::Wide, buffer: &mut FragmentBuffer) -> bool { + fn serial_split(&mut self, radius_squared: I::Wide, buffer: &mut FragmentBuffer) -> bool { let mut is_any_round = false; for group in buffer.groups.iter_mut() { if group.is_empty() { continue; } - let any_round = Self::bin_split(radius, group, &mut self.marks); + let any_round = Self::bin_split(radius_squared, group, &mut self.marks); is_any_round = is_any_round || any_round; } is_any_round } #[cfg(feature = "allow_multithreading")] - fn parallel_split(&mut self, radius: I::Wide, buffer: &mut FragmentBuffer) -> bool { + fn parallel_split(&mut self, radius_squared: I::Wide, buffer: &mut FragmentBuffer) -> bool { use rayon::iter::IntoParallelRefMutIterator; use rayon::iter::ParallelIterator; @@ -124,7 +123,7 @@ where .par_iter_mut() .map(|group| { let mut marks = Vec::with_capacity(marks_capacity); - let any_round = Self::bin_split(radius, group, &mut marks); + let any_round = Self::bin_split(radius_squared, group, &mut marks); TaskResult { any_round, marks } }) .collect(); @@ -152,7 +151,11 @@ where is_any_round } - fn bin_split(radius: I::Wide, fragments: &mut [Fragment], marks: &mut Vec>) -> bool { + fn bin_split( + radius_squared: I::Wide, + fragments: &mut [Fragment], + marks: &mut Vec>, + ) -> bool { if fragments.len() < 2 { return false; } @@ -173,9 +176,9 @@ where // MARK: the intersection, ensuring the right order for deterministic results let is_round = if fi.x_segment < fj.x_segment { - Self::cross_fragments(fi, fj, radius, marks) + Self::cross_fragments(fi, fj, radius_squared, marks) } else { - Self::cross_fragments(fj, fi, radius, marks) + Self::cross_fragments(fj, fi, radius_squared, marks) }; any_round = any_round || is_round @@ -224,94 +227,19 @@ where fn cross_fragments( fi: &Fragment, fj: &Fragment, - radius: I::Wide, + radius_squared: I::Wide, marks: &mut Vec>, ) -> bool { - let cross = if let Some(cross) = CrossSolver::::cross(&fi.x_segment, &fj.x_segment, radius) { - cross - } else { - return false; - }; - - let r = I::from_wide(radius); - - match cross.cross_type { - CrossType::Overlay => { - let mask = CrossSolver::::collinear(&fi.x_segment, &fj.x_segment); - if mask == 0 { - return false; - } - - if !(fi.rect.contains_with_radius(fi.x_segment.a, r) - || fj.rect.contains_with_radius(fi.x_segment.a, r)) - { - return false; - } - - if mask.is_target_a() { - marks.push(LineMark { - index: fj.index, - point: fi.x_segment.a, - }); - } - - if mask.is_target_b() { - marks.push(LineMark { - index: fj.index, - point: fi.x_segment.b, - }); - } - - if mask.is_other_a() { - marks.push(LineMark { - index: fi.index, - point: fj.x_segment.a, - }); - } - - if mask.is_other_b() { - marks.push(LineMark { - index: fi.index, - point: fj.x_segment.b, - }); - } - } - _ => { - if !fi.rect.contains_with_radius(cross.point, r) - || !fj.rect.contains_with_radius(cross.point, r) - { - return false; - } - - match cross.cross_type { - CrossType::Pure => { - marks.push(LineMark { - index: fi.index, - point: cross.point, - }); - marks.push(LineMark { - index: fj.index, - point: cross.point, - }); - } - CrossType::TargetEnd => { - marks.push(LineMark { - index: fj.index, - point: cross.point, - }); - } - CrossType::OtherEnd => { - marks.push(LineMark { - index: fi.index, - point: cross.point, - }); - } - _ => {} - } - } - } - - cross.is_round + // Fragments select candidate pairs; marks belong to the complete segments. + // Repeated marks from different columns are deduplicated in apply(). + Self::cross( + fi.index, + fj.index, + &fi.x_segment, + &fj.x_segment, + marks, + radius_squared, + ) } } @@ -326,6 +254,85 @@ mod tests { use alloc::vec::Vec; use i_float::int::point::IntPoint; + #[test] + fn collinear_overlap_starts_in_a_later_column() { + for (start, end, far_end) in [(80, 100, 150), (180, 300, 400)] { + for slope in [-1, 0, 1] { + let segment = |a, b, count| Segment { + x_segment: XSegment { a, b }, + count: ShapeCountBoolean { subj: count, clip: 0 }, + data: (), + }; + let point = |x| IntPoint::new(x, slope * x); + let padding = [ + segment(IntPoint::new(0, 1000), IntPoint::new(far_end, 1000), 1), + segment(IntPoint::new(0, 2000), IntPoint::new(far_end, 2000), 1), + ]; + let mut input = alloc::vec![ + segment(point(0), point(end), 1), + segment(point(start), point(far_end), 1) + ]; + input.extend(padding.iter().copied()); + input.sort_unstable(); + let layout = GridLayout::new(input.iter().map(|s| s.x_segment), input.len()).unwrap(); + assert!(layout.index(start) > layout.index(0)); + let mut expected = alloc::vec![ + segment(point(0), point(start), 1), + segment(point(start), point(end), 2), + segment(point(end), point(far_end), 1), + ]; + expected.extend(padding); + expected.sort_unstable(); + let expected: Vec<_> = expected.iter().map(|s| (s.x_segment, s.count)).collect(); + for solver in [Solver::LIST, Solver::TREE, Solver::FRAG] { + let mut actual = input.clone(); + SplitSolver::new().split_segments(&mut actual, &solver); + let actual: Vec<_> = actual.iter().map(|s| (s.x_segment, s.count)).collect(); + assert_eq!( + actual, expected, + "{:?}, slope={slope}, start={start}", + solver.strategy + ); + } + } + } + } + + #[test] + fn fragments_can_report_a_crossing_outside_their_column() { + use crate::split::grid_layout::FragmentBuffer; + let edges = [ + XSegment { + a: IntPoint::new(0, 0), + b: IntPoint::new(100, 1), + }, + XSegment { + a: IntPoint::new(0, 1), + b: IntPoint::new(100, 0), + }, + ]; + let layout = GridLayout::new(edges.iter().copied(), 4).unwrap(); + assert_eq!(layout.pos(1), 32); + let mut buffer = FragmentBuffer::new(layout); + for (index, edge) in edges.into_iter().enumerate() { + buffer.add_segment(index, edge); + } + let mut marks = Vec::new(); + // The first column's enclosures overlap, but the rounded crossing at + // (50, 1) is in a later column. Marks belong to the complete segments. + assert!(SplitSolver::::bin_split( + 1, + &mut buffer.groups[0], + &mut marks + )); + assert_eq!(marks.len(), 2); + assert_eq!(marks[0].index, 0); + assert_eq!(marks[1].index, 1); + for mark in marks { + assert_eq!(mark.point, IntPoint::new(50, 1)); + } + } + // Exercise the complete splitter so the tests include selection of the border's // neighboring group, not just on_border_split with an already selected group. fn assert_border_split(edges: &[[i32; 4]], expected_verticals: &[[i32; 4]]) { diff --git a/iOverlay/src/split/solver_list.rs b/iOverlay/src/split/solver_list.rs index 5070eac..c47de64 100644 --- a/iOverlay/src/split/solver_list.rs +++ b/iOverlay/src/split/solver_list.rs @@ -28,7 +28,7 @@ where need_to_fix = false; self.marks.clear(); - let radius = snap_radius.radius::(); + let radius_squared = snap_radius.radius_squared::(); for (i, si) in segments.iter().enumerate() { let xsi = &si.x_segment; @@ -43,7 +43,7 @@ where continue; } - let is_round = Self::cross(i, j, xsi, xsj, &mut self.marks, radius); + let is_round = Self::cross(i, j, xsi, xsj, &mut self.marks, radius_squared); need_to_fix = need_to_fix || is_round } } diff --git a/iOverlay/src/split/solver_tree.rs b/iOverlay/src/split/solver_tree.rs index 1ce193f..57f923d 100644 --- a/iOverlay/src/split/solver_tree.rs +++ b/iOverlay/src/split/solver_tree.rs @@ -60,7 +60,7 @@ where need_to_fix = false; self.marks.clear(); - let radius = snap_radius.radius::(); + let radius_squared = snap_radius.radius_squared::(); for (i, si) in segments.iter().enumerate() { let time = si.x_segment.a.x; @@ -72,7 +72,14 @@ where (sj.id, i, &sj.x_segment, &si.x_segment) }; - let is_round = Self::cross(this_index, scan_index, this, scan, &mut self.marks, radius); + let is_round = Self::cross( + this_index, + scan_index, + this, + scan, + &mut self.marks, + radius_squared, + ); need_to_fix = is_round || need_to_fix; } diff --git a/iOverlay/src/string/overlay.rs b/iOverlay/src/string/overlay.rs index 340838b..1da5649 100644 --- a/iOverlay/src/string/overlay.rs +++ b/iOverlay/src/string/overlay.rs @@ -20,6 +20,10 @@ use i_shape::int::count::PointsCount; use i_shape::int::path::IntPath; use i_shape::int::shape::{IntContour, IntShape}; +/// Integer polygon and string overlay builder. +/// +/// Polygon and string coordinates must satisfy the +/// [integer coordinate range](crate::core::integer). Bounds are not checked. pub struct StringOverlay { pub options: IntOverlayOptions, pub(super) segments: Vec>, diff --git a/iOverlay/src/vector/extract.rs b/iOverlay/src/vector/extract.rs index e137307..a133173 100644 --- a/iOverlay/src/vector/extract.rs +++ b/iOverlay/src/vector/extract.rs @@ -366,9 +366,12 @@ impl DataGraphContour for DataVectorPath return (true, is_modified); } - let double_area = self - .iter() - .fold(I::Wide::ZERO, |acc, edge| acc + edge.a.cross_product(edge.b)); + // A spiral can overflow a partial shoelace sum even though its final + // area fits. Accumulate modulo the wide type, as IntPath::unsafe_area + // does; the bounded final area is recovered after cancellation. + let double_area = self.iter().fold(I::Wide::ZERO, |acc, edge| { + acc.wrapping_add(edge.a.cross_product(edge.b)) + }); ((double_area.unsigned_abs() >> 1) >= min_output_area, is_modified) } diff --git a/iOverlay/tests/integer_range_tests.rs b/iOverlay/tests/integer_range_tests.rs new file mode 100644 index 0000000..3592240 --- /dev/null +++ b/iOverlay/tests/integer_range_tests.rs @@ -0,0 +1,460 @@ +use i_float::int::number::wide_int::WideIntNumber; +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::integer::OverlayInt; +use i_overlay::core::overlay::Overlay; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::solver::Solver; +use i_shape::int::area::Area; + +type Contour = Vec<[i64; 2]>; +type Shapes = Vec>; + +fn contour + Into>(points: &[[i64; 2]]) -> Vec> { + points + .iter() + .map(|p| { + IntPoint::new( + I::try_from(p[0]).ok().expect("coordinate fits engine"), + I::try_from(p[1]).ok().expect("coordinate fits engine"), + ) + }) + .collect() +} + +// Preserve winding and hole ownership, but ignore the starting vertex and shape order. +fn canonical(mut shapes: Shapes) -> Shapes { + for shape in &mut shapes { + for path in shape.iter_mut() { + let start = path.iter().enumerate().min_by_key(|(_, p)| **p).unwrap().0; + path.rotate_left(start); + } + shape[1..].sort(); + } + shapes.sort(); + shapes +} + +fn check + Into>( + subj: &[Contour], + clip: &[Contour], + rule: OverlayRule, + expected: Shapes, +) { + let subj: Vec<_> = subj.iter().map(|p| contour::(p)).collect(); + let clip: Vec<_> = clip.iter().map(|p| contour::(p)).collect(); + let expected = canonical(expected); + for solver in [Solver::LIST, Solver::TREE, Solver::FRAG, Solver::AUTO] { + let result = Overlay::::with_contours_custom(&subj, &clip, Default::default(), solver) + .overlay(rule, FillRule::EvenOdd); + // Also exercise the wide area accumulator at the maximum square size. + for shape in &result { + assert!(shape[0].area_two() > I::Wide::ZERO); + for hole in &shape[1..] { + assert!(hole.area_two() < I::Wide::ZERO); + } + } + let actual = result + .iter() + .map(|s| { + s.iter() + .map(|c| c.iter().map(|p| [p.x.into(), p.y.into()]).collect()) + .collect() + }) + .collect(); + assert_eq!( + canonical(actual), + expected, + "{} {:?}", + core::any::type_name::(), + solver.strategy + ); + } +} + +fn square(lo: i64, hi: i64) -> Contour { + vec![[lo, lo], [hi, lo], [hi, hi], [lo, hi]] +} + +fn boundaries + Into>() { + let half = 1_i64 << (I::BITS - 2); + let lo = -half; + let hi = half - 1; + let outer = square(lo, hi); + check::( + &[outer.clone()], + &[], + OverlayRule::Subject, + vec![vec![outer.clone()]], + ); + + // Keep unit-size features at both inclusive endpoints. + for a in [lo, hi - 1] { + let small = square(a, a + 1); + check::(&[small.clone()], &[], OverlayRule::Subject, vec![vec![small]]); + } + + let inner = square(-half / 2, half / 2); + let mut hole = inner.clone(); + hole.reverse(); + check::( + &[outer.clone()], + &[inner], + OverlayRule::Difference, + vec![vec![outer, hole]], + ); + + // Issue #88's steep edge, stretched to the maximum supported span. + // Reflect and transpose it to exercise increasing/decreasing fragments and both axes. + for transpose in [false, true] { + for reflect in [false, true] { + let mut triangle = vec![[0, lo], [129, hi], [0, hi]]; + for p in &mut triangle { + if reflect { + p[1] = -1 - p[1]; + } + if transpose { + p.swap(0, 1); + } + } + if reflect != transpose { + triangle.reverse(); + } + check::( + &[triangle.clone()], + &[], + OverlayRule::Subject, + vec![vec![triangle]], + ); + } + } + + // An exact diagonal intersection near both limits, with a non-axis-aligned divider. + let m = hi; + let bow_tie = vec![[-m, -m], [m, m], [-m, m], [m, -m]]; + check::( + &[bow_tie], + &[], + OverlayRule::Subject, + vec![ + vec![vec![[-m, -m], [m, -m], [0, 0]]], + vec![vec![[-m, m], [0, 0], [m, m]]], + ], + ); + + // An odd span puts the crossing at (-0.5, -0.5). Rounding to (0, 0) + // exercises squared distances and the subsequent segment repair pass. + let bow_tie = vec![[lo, lo], [hi, hi], [lo, hi], [hi, lo]]; + check::( + &[bow_tie], + &[], + OverlayRule::Subject, + vec![ + vec![vec![[lo, lo], [hi, lo], [0, 0]]], + vec![vec![[lo, hi], [0, 0], [hi, hi]]], + ], + ); + + // Large collinear overlaps and intersections on fragment boundaries. + let left = vec![[lo, lo], [0, lo], [0, hi], [lo, hi]]; + let bottom = vec![[lo, lo], [hi, lo], [hi, 0], [lo, 0]]; + check::( + &[left], + &[bottom], + OverlayRule::Intersect, + vec![vec![square(lo, 0)]], + ); +} + +#[test] +fn i16_boundaries() { + boundaries::(); +} + +#[test] +fn i32_boundaries() { + boundaries::(); +} + +#[test] +fn i64_boundaries() { + boundaries::(); +} + +#[test] +fn issue_88_with_a_wider_engine_or_rescaled_coordinates() { + let triangle = vec![[0, 0], [129, -23169], [0, 9854]]; + check::( + &[triangle.clone()], + &[], + OverlayRule::Subject, + vec![vec![triangle.clone()]], + ); + let scaled: Contour = triangle.iter().map(|p| [p[0] / 2, p[1] / 2]).collect(); + check::(&[scaled.clone()], &[], OverlayRule::Subject, vec![vec![scaled]]); +} + +#[test] +fn one_more_unit_of_span_exceeds_the_arithmetic_budget() { + // These checked calculations test the limit without relying on debug-only panics + // or promising any particular behavior for unsupported overlay inputs. + macro_rules! check_budget { + ($int:ty, $wide:ty) => {{ + let half: $int = 1 << (<$int>::BITS - 2); + let span = (half - 1).checked_sub(-half).unwrap(); + assert_eq!(span, <$int>::MAX); + let span = span as $wide; + assert!(span.checked_mul(span).unwrap().checked_mul(2).is_some()); + assert!(half.checked_sub(-half).is_none()); + let too_wide = span + 1; + assert!(too_wide.checked_mul(too_wide).unwrap().checked_mul(2).is_none()); + }}; + } + check_budget!(i16, i32); + check_budget!(i32, i64); + check_budget!(i64, i128); +} + +#[test] +fn conservative_float_scale_keeps_rounded_i16_span_in_range() { + use i_float::adapter::FloatPointAdapter; + use i_float::float::rect::FloatRect; + + let half_extent = 1.99999; + let rect = FloatRect::new(-half_extent, half_extent, -half_extent, half_extent); + let min = [-half_extent, -half_extent]; + let max = [half_extent, half_extent]; + + // Truncating log2(1.99999) to 0 selects scale 8192. + // 1.99999 * 8192 = 16383.91808 rounds to 16384: the span exceeds i16::MAX. + let old_scale = FloatPointAdapter::<[f64; 2], i16>::with_scale(rect, 8192.0); + let old_min = old_scale.float_to_int(&min); + let old_max = old_scale.float_to_int(&max); + assert_eq!((old_min.x, old_max.x), (-16384, 16384)); + assert_eq!(i32::from(old_max.x) - i32::from(old_min.x), 32768); + assert_eq!(old_max.x.checked_sub(old_min.x), None); + + let conservative = FloatPointAdapter::<[f64; 2], i16>::with_coordinate_bits(rect, i16::BITS - 3); + let safe_min = conservative.float_to_int(&min); + let safe_max = conservative.float_to_int(&max); + assert_eq!((safe_min.x, safe_max.x), (-8192, 8192)); + assert_eq!(safe_max.x.checked_sub(safe_min.x), Some(16384)); +} + +#[test] +fn explicit_float_coordinate_budget() { + use i_float::adapter::FloatPointAdapter; + use i_float::float::rect::FloatRect; + use i_overlay::core::overlay::ShapeType; + use i_overlay::float::overlay::FloatOverlay; + + fn check_adapter + Into>() { + let limit = 1_i64 << (I::BITS - 3); + // Power-of-two, non-power-of-two, and sub-unit bounds exercise scale rounding. + for half_extent in [1.0, 1.5, 0.25] { + let rect = FloatRect::new(-half_extent, half_extent, -half_extent, half_extent); + let adapter = FloatPointAdapter::<[f64; 2], I>::with_coordinate_bits(rect, I::BITS - 3); + let points = vec![ + [-half_extent, -half_extent], + [half_extent, -half_extent], + [half_extent, half_extent], + [-half_extent, half_extent], + ]; + for point in &points { + let p = adapter.float_to_int(point); + assert!((-limit..=limit).contains(&p.x.into())); + assert!((-limit..=limit).contains(&p.y.into())); + } + if half_extent != 1.5 { + assert_eq!(adapter.float_to_int(&points[0]).x.into(), -limit); + assert_eq!(adapter.float_to_int(&points[2]).x.into(), limit); + } + for solver in [Solver::LIST, Solver::TREE, Solver::FRAG, Solver::AUTO] { + let result = FloatOverlay::new_custom(adapter.clone(), Default::default(), solver, 4) + .unsafe_add_source(&points, ShapeType::Subject) + .overlay(OverlayRule::Subject, FillRule::EvenOdd); + assert_eq!(result.len(), 1); + assert_eq!(result[0].len(), 1); + let mut actual = result[0][0].clone(); + let mut expected = points.clone(); + actual.sort_by(|a, b| a.partial_cmp(b).unwrap()); + expected.sort_by(|a, b| a.partial_cmp(b).unwrap()); + assert_eq!(actual, expected); + } + } + } + check_adapter::(); + check_adapter::(); + check_adapter::(); +} + +#[test] +fn spiral_vector_area_at_coordinate_limits() { + fn check_spiral + Into>() { + let lo = -(1_i64 << (I::BITS - 2)); + let hi = -lo - 1; + // A simple, one-unit-wide spiral. Its shoelace partial sums encompass + // several full squares before the return path cancels them out. + let mut points = vec![ + [lo, lo], + [hi, lo], + [hi, hi], + [lo, hi], + [lo, lo + 4], + [hi - 4, lo + 4], + [hi - 4, hi - 4], + [lo + 4, hi - 4], + [lo + 4, lo + 8], + [hi - 8, lo + 8], + ]; + points.extend([ + [hi - 8, lo + 9], + [lo + 5, lo + 9], + [lo + 5, hi - 5], + [hi - 5, hi - 5], + [hi - 5, lo + 5], + [lo + 1, lo + 5], + [lo + 1, hi - 1], + [hi - 1, hi - 1], + [hi - 1, lo + 1], + [lo, lo + 1], + ]); + let input = vec![contour::(&points)]; + for solver in [Solver::LIST, Solver::TREE, Solver::FRAG, Solver::AUTO] { + let mut overlay = Overlay::::with_contours_custom(&input, &[], Default::default(), solver); + // Sum the nine rectangular runs of the corridor, subtracting their + // corner overlaps. Check filtering at the exact area and one above. + let area = I::MAX.to_wide() * I::Wide::from_u32(9) - I::Wide::from_u32(56); + overlay.options.min_output_area = area.to_uint(); + let vectors = overlay.build_shape_vectors(FillRule::EvenOdd, OverlayRule::Subject); + let actual = vectors + .iter() + .map(|s| { + s.iter() + .map(|c| c.iter().map(|e| [e.a.x.into(), e.a.y.into()]).collect()) + .collect() + }) + .collect(); + assert_eq!(canonical(actual), canonical(vec![vec![points.clone()]])); + overlay.options.min_output_area = (area + I::Wide::ONE).to_uint(); + assert!( + overlay + .build_shape_vectors(FillRule::EvenOdd, OverlayRule::Subject) + .is_empty() + ); + } + } + check_spiral::(); + check_spiral::(); + check_spiral::(); +} + +#[test] +fn fragment_radius_at_coordinate_limits() { + use i_overlay::core::solver::Precision; + fn check_radius + Into>() { + let hi = (1_i64 << (I::BITS - 2)) - 1; + for lo in [-hi, -hi - 1] { + let input = vec![contour::(&[[lo, lo], [hi, hi], [lo, hi], [hi, lo]])]; + // Symmetric bounds give an exact crossing; the odd span gives a rounded + // crossing and a subsequent repair pass. Check the squared-radius cap + // and exponent saturation without narrowing the threshold to I. + let cap = (2 * (I::BITS - 4)) as usize; + for start in [cap - 1, cap, cap + 1, usize::MAX] { + let solver = Solver { + precision: Precision { + start, + progression: 1, + }, + ..Solver::FRAG + }; + let actual = Overlay::::with_contours_custom(&input, &[], Default::default(), solver) + .overlay(OverlayRule::Subject, FillRule::EvenOdd); + let actual = actual + .iter() + .map(|s| { + s.iter() + .map(|c| c.iter().map(|p| [p.x.into(), p.y.into()]).collect()) + .collect() + }) + .collect(); + let expected = vec![ + vec![vec![[lo, lo], [hi, lo], [0, 0]]], + vec![vec![[lo, hi], [0, 0], [hi, hi]]], + ]; + assert_eq!(canonical(actual), canonical(expected)); + } + } + } + check_radius::(); + check_radius::(); + check_radius::(); +} + +#[test] +fn seeded_boundary_overlays_match_a_wider_engine() { + use rand::{RngExt, SeedableRng, rngs::StdRng}; + + fn check_engine + Into>() { + let mut rng = StdRng::seed_from_u64(88); + let lo = -(1_i64 << (I::BITS - 2)); + let hi = -lo - 1; + let extremes = [lo, lo + 1, lo + 2, -1, 0, 1, hi - 1, hi]; + for case in 0..256 { + let mut coordinate = || { + if rng.random_bool(0.75) { + extremes[rng.random_range(0..extremes.len())] + } else { + rng.random_range(lo..=hi) + } + }; + let subject: Contour = (0..6).map(|_| [coordinate(), coordinate()]).collect(); + let clip: Contour = (0..6).map(|_| [coordinate(), coordinate()]).collect(); + let rules = [ + OverlayRule::Subject, + OverlayRule::Union, + OverlayRule::Intersect, + OverlayRule::Difference, + OverlayRule::Xor, + ]; + let rule = rules[case % rules.len()]; + for solver in [Solver::LIST, Solver::TREE, Solver::FRAG, Solver::AUTO] { + let wide = Overlay::::with_contours_custom( + &[contour::(&subject)], + &[contour::(&clip)], + Default::default(), + solver, + ) + .overlay(rule, FillRule::EvenOdd); + let narrow = Overlay::::with_contours_custom( + &[contour::(&subject)], + &[contour::(&clip)], + Default::default(), + solver, + ) + .overlay(rule, FillRule::EvenOdd); + let expected = wide + .iter() + .map(|s| s.iter().map(|c| c.iter().map(|p| [p.x, p.y]).collect()).collect()) + .collect(); + let actual = narrow + .iter() + .map(|s| { + s.iter() + .map(|c| c.iter().map(|p| [p.x.into(), p.y.into()]).collect()) + .collect() + }) + .collect(); + assert_eq!( + canonical(actual), + canonical(expected), + "{} case {case} {:?}; subject={subject:?}, clip={clip:?}", + core::any::type_name::(), + solver.strategy + ); + } + } + } + check_engine::(); + check_engine::(); +}