From e5739ac0e542cfbbb4bf094d465234985f4a4424 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Fri, 4 Sep 2026 18:19:38 +0300 Subject: [PATCH 1/2] add int range documentation --- iOverlay/README.md | 20 ++ iOverlay/src/core/edge_overlay.rs | 4 + iOverlay/src/core/integer.rs | 21 ++ iOverlay/src/core/overlay.rs | 3 + iOverlay/src/core/relate.rs | 3 + iOverlay/src/lib.rs | 6 + iOverlay/src/split/cross_solver.rs | 6 +- iOverlay/src/string/overlay.rs | 4 + iOverlay/tests/integer_range_tests.rs | 263 ++++++++++++++++++++++++++ 9 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 iOverlay/tests/integer_range_tests.rs diff --git a/iOverlay/README.md b/iOverlay/README.md index 783fdde3..1d36abe0 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](src/core/integer.rs) 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/src/core/edge_overlay.rs b/iOverlay/src/core/edge_overlay.rs index 5b6da03e..2d436b8b 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 55702e21..38ead6b0 100644 --- a/iOverlay/src/core/integer.rs +++ b/iOverlay/src/core/integer.rs @@ -1,4 +1,24 @@ //! 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 | +//! +//! ## 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 +38,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 98fa06a4..d9596309 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 3caf0d14..3ba65ac2 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/lib.rs b/iOverlay/src/lib.rs index dd7cc886..6db3f922 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 c93cffcc..99d23a50 100644 --- a/iOverlay/src/split/cross_solver.rs +++ b/iOverlay/src/split/cross_solver.rs @@ -218,8 +218,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: diff --git a/iOverlay/src/string/overlay.rs b/iOverlay/src/string/overlay.rs index 340838b2..1da56493 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/tests/integer_range_tests.rs b/iOverlay/tests/integer_range_tests.rs new file mode 100644 index 00000000..bf9c5d0e --- /dev/null +++ b/iOverlay/tests/integer_range_tests.rs @@ -0,0 +1,263 @@ +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 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::(); +} From c56659af2aade1d54c74e648641870e96b55b09e Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Sun, 6 Sep 2026 15:09:23 +0300 Subject: [PATCH 2/2] fix int logic and description --- iOverlay/.gitignore | 1 + iOverlay/README.md | 2 +- iOverlay/readme/integer_range.md | 70 +++++++++ iOverlay/src/core/integer.rs | 6 + iOverlay/src/core/solver.rs | 20 ++- iOverlay/src/split/cross_solver.rs | 89 ++++++++++- iOverlay/src/split/grid_layout.rs | 76 +++++++++- iOverlay/src/split/snap_radius.rs | 48 +++++- iOverlay/src/split/solver.rs | 4 +- iOverlay/src/split/solver_fragment.rs | 203 +++++++++++++------------- iOverlay/src/split/solver_list.rs | 4 +- iOverlay/src/split/solver_tree.rs | 11 +- iOverlay/src/vector/extract.rs | 9 +- iOverlay/tests/integer_range_tests.rs | 197 +++++++++++++++++++++++++ 14 files changed, 615 insertions(+), 125 deletions(-) create mode 100644 iOverlay/.gitignore create mode 100644 iOverlay/readme/integer_range.md diff --git a/iOverlay/.gitignore b/iOverlay/.gitignore new file mode 100644 index 00000000..3412b312 --- /dev/null +++ b/iOverlay/.gitignore @@ -0,0 +1 @@ +/AGENTS.md diff --git a/iOverlay/README.md b/iOverlay/README.md index 1d36abe0..0465743d 100644 --- a/iOverlay/README.md +++ b/iOverlay/README.md @@ -598,7 +598,7 @@ For an `N`-bit engine, keep each input coordinate within 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](src/core/integer.rs) for details. +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)`. diff --git a/iOverlay/readme/integer_range.md b/iOverlay/readme/integer_range.md new file mode 100644 index 00000000..acb9c629 --- /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/integer.rs b/iOverlay/src/core/integer.rs index 38ead6b0..44027701 100644 --- a/iOverlay/src/core/integer.rs +++ b/iOverlay/src/core/integer.rs @@ -11,6 +11,12 @@ //! | `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 diff --git a/iOverlay/src/core/solver.rs b/iOverlay/src/core/solver.rs index 679fe1b2..a2a57888 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/split/cross_solver.rs b/iOverlay/src/split/cross_solver.rs index 99d23a50..c33938fa 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); @@ -319,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 2a90eaab..01813f12 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 08eb92af..32540ddd 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 fc8e23e6..afdffe6e 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 9b622c41..95eebdd7 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 5070eac8..c47de641 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 1ce193f3..57f923d7 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/vector/extract.rs b/iOverlay/src/vector/extract.rs index e1373073..a133173b 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 index bf9c5d0e..35922405 100644 --- a/iOverlay/tests/integer_range_tests.rs +++ b/iOverlay/tests/integer_range_tests.rs @@ -215,6 +215,32 @@ fn one_more_unit_of_span_exceeds_the_arithmetic_budget() { 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; @@ -261,3 +287,174 @@ fn explicit_float_coordinate_budget() { 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::(); +}