From 3920953f890434e7a8e583c77983ecf9cf1932b8 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Tue, 8 Sep 2026 14:03:44 +0300 Subject: [PATCH 01/44] fix area reinit and sqr_length overflow --- iOverlay/src/float/overlay.rs | 31 +++++++++- iOverlay/src/mesh/outline/builder_join.rs | 4 +- iOverlay/src/mesh/variable_stroke/builder.rs | 9 +-- iOverlay/src/mesh/variable_stroke/section.rs | 3 +- iOverlay/src/split/cross_solver.rs | 4 +- iOverlay/src/split/snap_radius.rs | 8 +-- iOverlay/src/split/solver.rs | 2 +- iOverlay/src/split/solver_fragment.rs | 15 +++-- iOverlay/tests/float_conversion_tests.rs | 33 +++++++++++ iOverlay/tests/float_reinit_tests.rs | 59 ++++++++++++++++++++ 10 files changed, 146 insertions(+), 22 deletions(-) create mode 100644 iOverlay/tests/float_conversion_tests.rs create mode 100644 iOverlay/tests/float_reinit_tests.rs diff --git a/iOverlay/src/float/overlay.rs b/iOverlay/src/float/overlay.rs index a02996b6..3bb3141f 100644 --- a/iOverlay/src/float/overlay.rs +++ b/iOverlay/src/float/overlay.rs @@ -265,6 +265,18 @@ where } } + #[inline] + fn update_adapter(&mut self, adapter: FloatPointAdapter) { + if self.overlay.options.min_output_area != I::WideUInt::ZERO { + let inv_scale = self.adapter.inv_scale().to_f64(); + let area = self.overlay.options.min_output_area.to_f64() * inv_scale * inv_scale; + self.overlay.options.min_output_area = adapter + .round_sqr_len_to_int(P::Scalar::from_float(area)) + .to_uint(); + } + self.adapter = adapter; + } + /// Reinit `FloatOverlay` instance and initializes it with subject and clip shapes. /// - `subj`: A `ShapeResource` that define the subject. /// - `clip`: A `ShapeResource` that define the clip. @@ -272,6 +284,13 @@ where /// - `Contour`: A contour representing a closed path. This path is interpreted as closed, so it doesn’t require the start and endpoint to be the same for processing. /// - `Contours`: A collection of contours, each representing a closed path. /// - `Shapes`: A collection of shapes, where each shape may consist of multiple contours. + /// + /// The current integer `min_output_area` is converted back to float area using + /// the previous adapter, then rounded to the new adapter's integer scale. + /// This approximately preserves the threshold in float units; repeated reinitialization + /// can lose precision. A threshold rounded to zero stays zero on subsequent calls. + /// An overlay created with `new_empty` starts with a zero threshold, so the + /// `min_output_area` originally passed to `new_empty` is not restored. pub fn reinit_with_subj_and_clip(&mut self, subj: &R0, clip: &R1) where R0: ShapeResource

+ ?Sized, @@ -280,7 +299,7 @@ where self.clear(); let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - self.adapter = FloatPointAdapter::with_iter(iter); + self.update_adapter(FloatPointAdapter::with_iter(iter)); self.add_source(subj, ShapeType::Subject); self.add_source(clip, ShapeType::Clip); } @@ -291,14 +310,20 @@ where /// - `Contour`: A contour representing a closed path. This path is interpreted as closed, so it doesn’t require the start and endpoint to be the same for processing. /// - `Contours`: A collection of contours, each representing a closed path. /// - `Shapes`: A collection of shapes, where each shape may consist of multiple contours. + /// + /// The current integer `min_output_area` is converted back to float area using + /// the previous adapter, then rounded to the new adapter's integer scale. + /// This approximately preserves the threshold in float units; repeated reinitialization + /// can lose precision. A threshold rounded to zero stays zero on subsequent calls. + /// An overlay created with `new_empty` starts with a zero threshold, so the + /// `min_output_area` originally passed to `new_empty` is not restored. pub fn reinit_with_subj(&mut self, subj: &R) where R: ShapeResource

+ ?Sized, { self.clear(); - let iter = subj.iter_paths().flatten(); - self.adapter = FloatPointAdapter::with_iter(iter); + self.update_adapter(FloatPointAdapter::with_iter(iter)); self.add_source(subj, ShapeType::Subject); } diff --git a/iOverlay/src/mesh/outline/builder_join.rs b/iOverlay/src/mesh/outline/builder_join.rs index 835ca768..5ffbf2c6 100644 --- a/iOverlay/src/mesh/outline/builder_join.rs +++ b/iOverlay/src/mesh/outline/builder_join.rs @@ -10,7 +10,7 @@ use i_float::float::compatible::FloatPointCompatible; use i_float::float::number::FloatNumber; use i_float::float::vector::FloatPointMath; use i_float::int::number::int::IntNumber; -use i_float::int::number::wide_int::WideIntNumber; +use i_float::int::number::uint::UIntNumber; pub(super) trait JoinBuilder { fn add_join( @@ -105,7 +105,7 @@ impl JoinBuilder for MiterJoinBuild let ib = s1.a_top; let sq_len = ia.sqr_distance(ib); - if sq_len < I::Wide::from_usize(4) { + if sq_len < I::WideUInt::from_u64(4) { BevelJoinBuilder::join(s0, s1, adapter, segments); return; } diff --git a/iOverlay/src/mesh/variable_stroke/builder.rs b/iOverlay/src/mesh/variable_stroke/builder.rs index a4bbb5d0..712dd5bd 100644 --- a/iOverlay/src/mesh/variable_stroke/builder.rs +++ b/iOverlay/src/mesh/variable_stroke/builder.rs @@ -10,6 +10,7 @@ use i_float::float::compatible::FloatPointCompatible; use i_float::float::number::FloatNumber; use i_float::float::vector::FloatPointMath; use i_float::int::number::int::IntNumber; +use i_float::int::number::uint::UIntNumber; use i_float::int::number::wide_int::WideIntNumber; #[cfg(feature = "variable_stroke_debug")] @@ -209,7 +210,7 @@ impl VariableStrokeBuilder { let radius_delta = a_radius.to_wide() - b_radius.to_wide(); let distance_sqr = (int_b - int_a).sqr_length(); - if radius_delta * radius_delta < distance_sqr { + if (radius_delta * radius_delta).to_uint() < distance_sqr { return None; } @@ -248,7 +249,7 @@ impl VariableStrokeBuilder { adapter.float_to_int(§ion.a_right), ]; let center = adapter.float_to_int(&c.point); - let radius = c_radius.to_wide(); + let radius = c_radius.to_wide().to_uint(); let first_edge = points[1] - points[0]; let orientation = first_edge.cross_product(points[2] - points[1]); if orientation == I::Wide::ZERO { @@ -268,9 +269,9 @@ impl VariableStrokeBuilder { let length_sqr = edge.sqr_length(); let mut length = length_sqr.isqrt(); if length * length < length_sqr { - length = length + I::Wide::ONE; + length += I::WideUInt::ONE; } - if interior_distance < radius * length { + if interior_distance.to_uint() < radius * length { return false; } } diff --git a/iOverlay/src/mesh/variable_stroke/section.rs b/iOverlay/src/mesh/variable_stroke/section.rs index 1e6d9a16..1e52ff12 100644 --- a/iOverlay/src/mesh/variable_stroke/section.rs +++ b/iOverlay/src/mesh/variable_stroke/section.rs @@ -5,6 +5,7 @@ use i_float::float::compatible::FloatPointCompatible; use i_float::float::number::FloatNumber; use i_float::float::vector::FloatPointMath; use i_float::int::number::int::IntNumber; +use i_float::int::number::wide_int::WideIntNumber; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum RadiusTrend { @@ -53,7 +54,7 @@ impl Section

{ let vector = int_b - int_a; let int_distance_sqr = vector.sqr_length(); - if int_radius_delta * int_radius_delta >= int_distance_sqr { + if (int_radius_delta * int_radius_delta).to_uint() >= int_distance_sqr { return None; } diff --git a/iOverlay/src/split/cross_solver.rs b/iOverlay/src/split/cross_solver.rs index c33938fa..2a0f3f5b 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_squared: I::Wide, + radius_squared: I::WideUInt, ) -> Option> { let a0b0a1 = Triangle::clock_direction(target.a, target.b, other.a); let a0b0b1 = Triangle::clock_direction(target.a, target.b, other.b); @@ -164,7 +164,7 @@ impl CrossSolver { fn middle_cross( target: &XSegment, other: &XSegment, - radius_squared: I::Wide, + radius_squared: I::WideUInt, ) -> Option> { let p = CrossSolver::cross_point(target, other); diff --git a/iOverlay/src/split/snap_radius.rs b/iOverlay/src/split/snap_radius.rs index 32540ddd..a6ce734b 100644 --- a/iOverlay/src/split/snap_radius.rs +++ b/iOverlay/src/split/snap_radius.rs @@ -1,6 +1,6 @@ use crate::core::solver::Solver; use i_float::int::number::int::IntNumber; -use i_float::int::number::wide_int::WideIntNumber; +use i_float::int::number::uint::UIntNumber; pub(super) struct SnapRadius { current: usize, @@ -13,9 +13,9 @@ impl SnapRadius { } /// Squared-distance threshold for snapping to an existing endpoint. - pub(super) fn radius_squared(&self) -> I::Wide { + pub(super) fn radius_squared(&self) -> I::WideUInt { let exponent = self.current.min((2 * (I::BITS - 4)) as usize) as u32; - I::Wide::ONE << exponent + I::WideUInt::ONE << exponent } } @@ -49,7 +49,7 @@ mod tests { current: $exponent - 1, step: 1, }; - let limit: <$int as i_float::int::number::int::IntNumber>::Wide = 1 << $exponent; + let limit: <$int as i_float::int::number::int::IntNumber>::WideUInt = 1 << $exponent; assert_eq!(snap.radius_squared::<$int>(), limit / 2); snap.increment(); assert_eq!(snap.radius_squared::<$int>(), limit); diff --git a/iOverlay/src/split/solver.rs b/iOverlay/src/split/solver.rs index afdffe6e..e774a90f 100644 --- a/iOverlay/src/split/solver.rs +++ b/iOverlay/src/split/solver.rs @@ -85,7 +85,7 @@ where ei: &XSegment, ej: &XSegment, marks: &mut Vec>, - radius_squared: I::Wide, + radius_squared: I::WideUInt, ) -> bool { let cross = if let Some(cross) = CrossSolver::::cross(ei, ej, radius_squared) { cross diff --git a/iOverlay/src/split/solver_fragment.rs b/iOverlay/src/split/solver_fragment.rs index 95eebdd7..266afbee 100644 --- a/iOverlay/src/split/solver_fragment.rs +++ b/iOverlay/src/split/solver_fragment.rs @@ -81,7 +81,12 @@ where } #[inline] - fn process(&mut self, radius_squared: I::Wide, buffer: &mut FragmentBuffer, _solver: &Solver) -> bool { + fn process( + &mut self, + radius_squared: I::WideUInt, + buffer: &mut FragmentBuffer, + _solver: &Solver, + ) -> bool { #[cfg(feature = "allow_multithreading")] { if _solver.multithreading.is_some() { @@ -93,7 +98,7 @@ where } #[inline] - fn serial_split(&mut self, radius_squared: I::Wide, buffer: &mut FragmentBuffer) -> bool { + fn serial_split(&mut self, radius_squared: I::WideUInt, buffer: &mut FragmentBuffer) -> bool { let mut is_any_round = false; for group in buffer.groups.iter_mut() { if group.is_empty() { @@ -106,7 +111,7 @@ where } #[cfg(feature = "allow_multithreading")] - fn parallel_split(&mut self, radius_squared: I::Wide, buffer: &mut FragmentBuffer) -> bool { + fn parallel_split(&mut self, radius_squared: I::WideUInt, buffer: &mut FragmentBuffer) -> bool { use rayon::iter::IntoParallelRefMutIterator; use rayon::iter::ParallelIterator; @@ -152,7 +157,7 @@ where } fn bin_split( - radius_squared: I::Wide, + radius_squared: I::WideUInt, fragments: &mut [Fragment], marks: &mut Vec>, ) -> bool { @@ -227,7 +232,7 @@ where fn cross_fragments( fi: &Fragment, fj: &Fragment, - radius_squared: I::Wide, + radius_squared: I::WideUInt, marks: &mut Vec>, ) -> bool { // Fragments select candidate pairs; marks belong to the complete segments. diff --git a/iOverlay/tests/float_conversion_tests.rs b/iOverlay/tests/float_conversion_tests.rs new file mode 100644 index 00000000..3f9fdb84 --- /dev/null +++ b/iOverlay/tests/float_conversion_tests.rs @@ -0,0 +1,33 @@ +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::integer::OverlayInt; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::float::overlay::FloatOverlay; + +fn check_subject_near_scale_boundary(side: f64, grid_step: f64) { + let subject = [[0.0, 0.0], [side, side], [0.0, side]]; + let clip = [ + [side - grid_step, side], + [side, side - grid_step], + [side, side], + ]; + + // Both inputs have the same combined bounds as the subject alone. The + // small clip crosses its diagonal near the top-right corner. Extracting + // Subject must preserve that triangle, regardless of the clip. + let expected = FloatOverlay::<[f64; 2], I>::from_subj(&subject) + .overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(expected.len(), 1); + let actual = FloatOverlay::<[f64; 2], I>::from_subj_and_clip(&subject, &clip) + .overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(actual, expected); +} + +#[test] +fn automatic_i16_scale_handles_intersection_near_extreme_corner() { + check_subject_near_scale_boundary::(3.9999, 1.0 / 8192.0); +} + +#[test] +fn automatic_i32_scale_handles_intersection_near_extreme_corner() { + check_subject_near_scale_boundary::(3.999999999, 1.0 / 536870912.0); +} diff --git a/iOverlay/tests/float_reinit_tests.rs b/iOverlay/tests/float_reinit_tests.rs new file mode 100644 index 00000000..c264f13b --- /dev/null +++ b/iOverlay/tests/float_reinit_tests.rs @@ -0,0 +1,59 @@ +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::solver::Solver; +use i_overlay::float::overlay::{FloatOverlay, OverlayOptions}; + +fn square(side: f64) -> [[f64; 2]; 4] { + [[0.0, 0.0], [side, 0.0], [side, side], [0.0, side]] +} + +fn area_options() -> OverlayOptions { + let mut options = OverlayOptions::default(); + options.min_output_area = 2.0; + options +} + +#[test] +fn reinit_subject_preserves_minimum_area_in_float_units() { + for (old_side, new_side, expected_count) in [(1.0, 4.0, 1), (4.0, 1.0, 0)] { + let options = area_options(); + let subject = square(new_side); + let mut reused = FloatOverlay::with_subj_custom(&square(old_side), options, Solver::AUTO); + reused.reinit_with_subj(&subject); + + let expected = FloatOverlay::with_subj_custom(&subject, options, Solver::AUTO) + .overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(expected.len(), expected_count); + assert_eq!( + reused.overlay(OverlayRule::Subject, FillRule::NonZero), + expected, + "reinitializing from side {old_side} to {new_side} must retain the area threshold" + ); + } +} + +#[test] +fn reinit_subject_and_clip_preserves_minimum_area_in_float_units() { + for (old_side, new_side, expected_count) in [(1.0, 4.0, 1), (4.0, 1.0, 0)] { + let options = area_options(); + let subject = square(new_side); + let clip = square(new_side / 2.0); + let mut reused = FloatOverlay::with_subj_and_clip_custom( + &square(old_side), + &square(old_side / 2.0), + options, + Solver::AUTO, + ); + reused.reinit_with_subj_and_clip(&subject, &clip); + + let expected = + FloatOverlay::with_subj_and_clip_custom(&subject, &clip, options, Solver::AUTO) + .overlay(OverlayRule::Union, FillRule::NonZero); + assert_eq!(expected.len(), expected_count); + assert_eq!( + reused.overlay(OverlayRule::Union, FillRule::NonZero), + expected, + "reinitializing from side {old_side} to {new_side} must retain the area threshold" + ); + } +} From a5aa82ffb2233fa2d9a29117c813f5356c6ac74c Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Wed, 9 Sep 2026 12:47:52 +0300 Subject: [PATCH 02/44] fix custom cap bounding range --- iOverlay/Cargo.toml | 16 +++++++------- iOverlay/src/mesh/stroke/builder_cap.rs | 9 ++++---- iOverlay/src/mesh/stroke/offset.rs | 29 +++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/iOverlay/Cargo.toml b/iOverlay/Cargo.toml index 513428fd..2d45c275 100644 --- a/iOverlay/Cargo.toml +++ b/iOverlay/Cargo.toml @@ -15,11 +15,11 @@ categories = ["algorithms", "graphics", "science::geo", "mathematics", "no-std"] i_tree = { version = "^0.19.0" } i_key_sort = { version = "^0.11.0" } -i_float = { version = "^4.1.0"} -i_shape = { version = "^4.0.0"} +#i_float = { version = "^4.1.0"} +#i_shape = { version = "^4.0.0"} -#i_float = { path = "../../iFloat"} -#i_shape = { path = "../../iShape"} +i_float = { path = "../../iFloat"} +i_shape = { path = "../../iShape"} #i_tree = { path = "../../iTree" } #i_key_sort = { path = "../../iKeySort" } @@ -38,7 +38,7 @@ variable_stroke_debug = [] serde = { version = "^1.0", features = ["derive"] } serde_json = "^1.0" rand = { version = "~0.10", features = ["alloc"] } -i_float = { version = "^4.1.0", features = ["serde"] } -i_shape = { version = "^4.0.0", features = ["serde"] } -#i_float = { path = "../../iFloat", features = ["serde"] } -#i_shape = { path = "../../iShape", features = ["serde"] } +#i_float = { version = "^4.1.0", features = ["serde"] } +#i_shape = { version = "^4.0.0", features = ["serde"] } +i_float = { path = "../../iFloat", features = ["serde"] } +i_shape = { path = "../../iShape", features = ["serde"] } diff --git a/iOverlay/src/mesh/stroke/builder_cap.rs b/iOverlay/src/mesh/stroke/builder_cap.rs index d580a4f5..14cf9145 100644 --- a/iOverlay/src/mesh/stroke/builder_cap.rs +++ b/iOverlay/src/mesh/stroke/builder_cap.rs @@ -9,7 +9,6 @@ use core::f64::consts::PI; use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; use i_float::float::number::FloatNumber; -use i_float::float::rect::FloatRect; use i_float::float::vector::FloatPointMath; use i_float::int::number::int::IntNumber; @@ -121,11 +120,11 @@ impl CapBuilder

{ #[inline] pub(super) fn additional_offset(&self) -> P::Scalar { if let Some(points) = &self.points { - if let Some(rect) = FloatRect::with_iter(points.iter()) { - rect.width() + rect.height() - } else { - P::Scalar::from_float(0.0) + let mut max_sqr_radius = P::Scalar::from_float(0.0); + for point in points { + max_sqr_radius = max_sqr_radius.max(FloatPointMath::sqr_length(point)); } + max_sqr_radius.sqrt() } else { P::Scalar::from_float(0.0) } diff --git a/iOverlay/src/mesh/stroke/offset.rs b/iOverlay/src/mesh/stroke/offset.rs index 3b18c960..989bd1d2 100644 --- a/iOverlay/src/mesh/stroke/offset.rs +++ b/iOverlay/src/mesh/stroke/offset.rs @@ -587,11 +587,40 @@ where mod tests { use crate::mesh::stroke::offset::StrokeOffset; use crate::mesh::style::{LineCap, LineJoin, StrokeStyle}; + use alloc::rc::Rc; use alloc::vec; use alloc::vec::Vec; use core::f32::consts::PI; use i_shape::flat::float::FloatFlatContoursBuffer; + #[test] + fn test_custom_cap_extent_is_included_in_stroke_bounds() { + let path = [[0.0_f64, 0.0], [10.0, 0.0]]; + // The template has zero width and height, but extends 100 radii + // from the endpoint after rotation and translation. + let cap = LineCap::Custom(Rc::from(vec![[100.0, 0.0]])); + let style = StrokeStyle::new(2.0) + .start_cap(cap.clone()) + .end_cap(cap); + + let shapes = path.stroke(style, false); + + assert_eq!(shapes.len(), 1); + assert_eq!(shapes[0].len(), 1); + let contour = &shapes[0][0]; + assert_eq!(contour.len(), 6); + for point in [ + [-100.0, 0.0], + [0.0, -1.0], + [10.0, -1.0], + [110.0, 0.0], + [10.0, 1.0], + [0.0, 1.0], + ] { + assert!(contour.contains(&point), "missing cap/stroke vertex: {point:?}"); + } + } + #[test] fn test_doc() { let path = [ From 6171efcfa0c7f5a01cd9a85de0e5a298fe85714d Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Wed, 9 Sep 2026 13:07:37 +0300 Subject: [PATCH 03/44] fix zero length path buffering --- iOverlay/src/mesh/stroke/builder.rs | 2 +- iOverlay/tests/critical_hypotheses_tests.rs | 213 ++++++++++++++++++++ 2 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 iOverlay/tests/critical_hypotheses_tests.rs diff --git a/iOverlay/src/mesh/stroke/builder.rs b/iOverlay/src/mesh/stroke/builder.rs index d5ac0b77..b719023f 100644 --- a/iOverlay/src/mesh/stroke/builder.rs +++ b/iOverlay/src/mesh/stroke/builder.rs @@ -112,7 +112,7 @@ impl, P: FloatPointCompatible, I: IntNumber> StrokeBuild usize { if is_closed_path { - self.join_builder.capacity() * points_count - 2 + (self.join_builder.capacity() * points_count).saturating_sub(2) } else { self.join_builder.capacity() * (points_count.saturating_sub(1)) + paths_count * (self.end_cap_builder.capacity() + self.start_cap_builder.capacity()) diff --git a/iOverlay/tests/critical_hypotheses_tests.rs b/iOverlay/tests/critical_hypotheses_tests.rs new file mode 100644 index 00000000..c5b3ecbc --- /dev/null +++ b/iOverlay/tests/critical_hypotheses_tests.rs @@ -0,0 +1,213 @@ +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::ShapeType; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::relate::PredicateOverlay; +use i_overlay::core::solver::Solver; +use i_overlay::mesh::stroke::offset::StrokeOffset; +use i_overlay::mesh::style::StrokeStyle; + +fn rectangle(x0: i32, y0: i32, x1: i32, y1: i32) -> Vec { + vec![ + IntPoint::new(x0, y0), + IntPoint::new(x1, y0), + IntPoint::new(x1, y1), + IntPoint::new(x0, y1), + ] +} + +#[test] +fn hypothesis_closed_stroke_of_empty_path_returns_empty() { + let path: Vec<[f64; 2]> = Vec::new(); + assert!(path.stroke(StrokeStyle::new(1.0), true).is_empty()); +} + +#[test] +fn hypothesis_closed_stroke_of_empty_contours_returns_empty() { + let paths: Vec> = vec![Vec::new(), Vec::new()]; + assert!(paths.stroke(StrokeStyle::new(1.0), true).is_empty()); +} + +#[test] +fn hypothesis_fixed_scale_closed_stroke_of_empty_path_returns_empty() { + let path: Vec<[f64; 2]> = Vec::new(); + assert!( + path.stroke_fixed_scale(StrokeStyle::new(1.0), true, 100.0) + .unwrap() + .is_empty() + ); +} + +#[test] +fn hypothesis_closed_stroke_into_of_empty_path_returns_empty() { + let path: Vec<[f64; 2]> = Vec::new(); + let mut output = Default::default(); + path.stroke_into(StrokeStyle::new(1.0), true, &mut output); + assert!(output.points.is_empty()); + assert!(output.ranges.is_empty()); +} + +#[test] +fn empty_stroke_controls() { + let path: Vec<[f64; 2]> = Vec::new(); + assert!(path.stroke(StrokeStyle::new(1.0), false).is_empty()); + let paths: Vec> = Vec::new(); + assert!(paths.stroke(StrokeStyle::new(1.0), true).is_empty()); + let point = [[0.0, 0.0]]; + assert!(point.stroke(StrokeStyle::new(1.0), true).is_empty()); +} + +fn normalized_overlay + Into>( + subj: &[[i32; 2]], + clip: &[[i32; 2]], + rule: OverlayRule, + solver: Solver, +) -> Vec>> { + let convert = |path: &[[i32; 2]]| -> Vec> { + path.iter() + .map(|p| IntPoint::new(I::try_from(p[0]).ok().unwrap(), I::try_from(p[1]).ok().unwrap())) + .collect() + }; + let output = Overlay::with_contour_custom(&convert(subj), &convert(clip), Default::default(), solver) + .overlay(rule, FillRule::EvenOdd); + let mut output: Vec>> = output + .into_iter() + .map(|shape| { + let mut shape: Vec> = shape + .into_iter() + .map(|path| { + let mut path: Vec<_> = path.into_iter().map(|p| [p.x.into(), p.y.into()]).collect(); + let start = path.iter().enumerate().min_by_key(|(_, p)| **p).unwrap().0; + path.rotate_left(start); + path + }) + .collect(); + shape[1..].sort(); + shape + }) + .collect(); + output.sort(); + output +} + +#[test] +fn hypothesis_integer_engines_agree_near_i16_limits() { + let mut state = 0x47a3_9012_771b_81d1_u64; + let mut next = || { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); + ((state >> 32) % 32768) as i32 - 16384 + }; + for case in 0..2000 { + let subj: Vec<_> = (0..3 + case % 8).map(|_| [next(), next()]).collect(); + let clip: Vec<_> = (0..3 + case % 7).map(|_| [next(), next()]).collect(); + for rule in [ + OverlayRule::Intersect, + OverlayRule::Union, + OverlayRule::Difference, + OverlayRule::Xor, + ] { + let expected = normalized_overlay::(&subj, &clip, rule, Solver::LIST); + for solver in [Solver::LIST, Solver::TREE, Solver::FRAG] { + assert_eq!( + normalized_overlay::(&subj, &clip, rule, solver), + expected, + "i16 case={case}, rule={rule:?}, solver={:?}, subj={subj:?}, clip={clip:?}", + solver.strategy + ); + assert_eq!( + normalized_overlay::(&subj, &clip, rule, solver), + expected, + "i32 case={case}, rule={rule:?}, solver={:?}, subj={subj:?}, clip={clip:?}", + solver.strategy + ); + } + } + } +} + +#[test] +fn hypothesis_predicates_remain_correct_after_early_exit_and_clear() { + for solver in [Solver::LIST, Solver::TREE, Solver::FRAG] { + let mut overlay = PredicateOverlay::new(8); + overlay.solver = solver; + for x0 in -2..=2 { + for y0 in -2..=2 { + for x1 in x0 + 1..=3 { + for y1 in y0 + 1..=3 { + overlay.clear(); + overlay.add_contour(&rectangle(0, 0, 2, 2), ShapeType::Subject); + overlay.add_contour(&rectangle(x0, y0, x1, y1), ShapeType::Clip); + let intersects = x0 <= 2 && y0 <= 2 && x1 >= 0 && y1 >= 0; + let interiors = x0 < 2 && y0 < 2 && x1 > 0 && y1 > 0; + let within = x0 <= 0 && y0 <= 0 && x1 >= 2 && y1 >= 2; + for repeat in 0..2 { + let actual = ( + overlay.intersects(), + overlay.interiors_intersect(), + overlay.touches(), + overlay.within(), + ); + assert_eq!( + actual, + (intersects, interiors, intersects && !interiors, within), + "clip=({x0},{y0})..({x1},{y1}), solver={:?}, repeat={repeat}", + solver.strategy, + ); + } + } + } + } + } + } +} + +#[test] +fn hypothesis_predicates_agree_with_boolean_results_on_dense_inputs() { + let mut state = 0xb641_7890_a113_3145_u64; + let mut next = || { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); + ((state >> 32) % 17) as i32 - 8 + }; + for case in 0..5000 { + let subj: Vec<_> = (0..3 + case % 8).map(|_| IntPoint::new(next(), next())).collect(); + let clip: Vec<_> = (0..3 + case % 7).map(|_| IntPoint::new(next(), next())).collect(); + for fill_rule in [ + FillRule::EvenOdd, + FillRule::NonZero, + FillRule::Positive, + FillRule::Negative, + ] { + let mut overlay = Overlay::with_contour(&subj, &clip); + let intersection = overlay.overlay(OverlayRule::Intersect, fill_rule); + let difference = overlay.overlay(OverlayRule::Difference, fill_rule); + let subject = overlay.overlay(OverlayRule::Subject, fill_rule); + let mut predicates = PredicateOverlay::new(subj.len() + clip.len()); + predicates.fill_rule = fill_rule; + predicates.add_contour(&subj, ShapeType::Subject); + predicates.add_contour(&clip, ShapeType::Clip); + assert_eq!( + predicates.interiors_intersect(), + !intersection.is_empty(), + "interiors: case={case}, fill={fill_rule:?}, subj={subj:?}, clip={clip:?}" + ); + assert_eq!( + predicates.within(), + !subject.is_empty() && difference.is_empty(), + "within: case={case}, fill={fill_rule:?}, subj={subj:?}, clip={clip:?}" + ); + if !intersection.is_empty() { + assert!( + predicates.intersects(), + "intersects: case={case}, fill={fill_rule:?}" + ); + assert!(!predicates.touches(), "touches: case={case}, fill={fill_rule:?}"); + assert!( + !predicates.point_intersects(), + "point: case={case}, fill={fill_rule:?}" + ); + } + } + } +} From 7fbaad036e6082fbcf26d6460aa7c0751be193d6 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Wed, 9 Sep 2026 21:12:06 +0300 Subject: [PATCH 04/44] float area filter fix --- iOverlay/tests/float_area_tests.rs | 66 ++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 iOverlay/tests/float_area_tests.rs diff --git a/iOverlay/tests/float_area_tests.rs b/iOverlay/tests/float_area_tests.rs new file mode 100644 index 00000000..46dc831e --- /dev/null +++ b/iOverlay/tests/float_area_tests.rs @@ -0,0 +1,66 @@ +use i_float::adapter::FloatPointAdapter; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::solver::Solver; +use i_overlay::float::overlay::{FloatOverlay, OverlayOptions}; + +#[test] +fn f32_i64_area_filter_preserves_shapes_above_threshold() { + let square = [[0.0_f32, 0.0], [0.01, 0.0], [0.01, 0.01], [0.0, 0.01]]; + // Square area is 1e-4, one hundred times the filtering threshold. + let expected = + FloatOverlay::<[f32; 2], i64>::from_subj(&square).overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(expected.len(), 1); + let mut options = OverlayOptions::::default(); + options.min_output_area = 1e-6; + let actual = FloatOverlay::<[f32; 2], i64>::from_subj_custom(&square, options, Solver::AUTO) + .overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!( + actual, expected, + "a positive area threshold below the square area must retain it" + ); +} + +#[test] +fn adapter_area_conversion_avoids_intermediate_overflow() { + let square = [[0.0_f32, 0.0], [0.01, 0.0], [0.01, 0.01], [0.0, 0.01]]; + let adapter = FloatPointAdapter::<[f32; 2], i64>::with_iter(square.iter()); + let threshold = 1e-6_f32; + let scale = f64::from(adapter.dir_scale()); + let expected = (scale * scale * f64::from(threshold)).round() as i128; + assert!(expected > 0 && expected < i128::MAX); + assert_eq!(adapter.round_sqr_len_to_int(threshold), expected); +} + +#[test] +fn f32_i32_area_filter_preserves_shapes_above_threshold() { + let square = [[0.0_f32, 0.0], [0.01, 0.0], [0.01, 0.01], [0.0, 0.01]]; + let mut options = OverlayOptions::::default(); + options.min_output_area = 1e-6; + let actual = FloatOverlay::<[f32; 2], i32>::from_subj_custom(&square, options, Solver::AUTO) + .overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(actual.len(), 1); +} + +#[test] +fn f64_i64_area_filter_preserves_shapes_above_threshold() { + let square = [[0.0_f64, 0.0], [0.01, 0.0], [0.01, 0.01], [0.0, 0.01]]; + let mut options = OverlayOptions::::default(); + options.min_output_area = 1e-6; + let actual = FloatOverlay::<[f64; 2], i64>::from_subj_custom(&square, options, Solver::AUTO) + .overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(actual.len(), 1); +} + +#[test] +fn f32_i32_area_filter_preserves_tiny_shapes() { + let square = [[0.0_f32, 0.0], [1e-12, 0.0], [1e-12, 1e-12], [0.0, 1e-12]]; + let expected = + FloatOverlay::<[f32; 2], i32>::from_subj(&square).overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(expected.len(), 1); + let mut options = OverlayOptions::::default(); + options.min_output_area = 1e-26; + let actual = FloatOverlay::<[f32; 2], i32>::from_subj_custom(&square, options, Solver::AUTO) + .overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(actual, expected); +} From 16e8651378134c78b0dce7376e1b81f7206ee567 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Wed, 9 Sep 2026 21:21:11 +0300 Subject: [PATCH 05/44] remove devide api --- iOverlay/src/core/divide.rs | 289 ------------------------------------ iOverlay/src/core/mod.rs | 1 - 2 files changed, 290 deletions(-) delete mode 100644 iOverlay/src/core/divide.rs diff --git a/iOverlay/src/core/divide.rs b/iOverlay/src/core/divide.rs deleted file mode 100644 index 1b61680b..00000000 --- a/iOverlay/src/core/divide.rs +++ /dev/null @@ -1,289 +0,0 @@ -use crate::geom::id_point::IdPoint; -use alloc::vec; -use alloc::vec::Vec; -use i_float::int::number::int::IntNumber; -use i_float::int::point::IntPoint; -use i_shape::int::path::IntPath; -use i_shape::int::shape::IntContour; - -struct SubPath { - last: usize, - node: IntPoint, - path: IntPath, -} - -impl SubPath { - fn start(point: IdPoint) -> Self { - Self { - last: point.id + 1, - node: point.point, - path: vec![point.point], - } - } - - fn join(&mut self, point: IdPoint, source: &IntContour) { - self.path.extend_from_slice(&source[self.last..point.id]); - self.last = point.id; - } - - fn shift(&mut self, point: IdPoint) { - self.last = point.id; - } -} - -pub trait ContourDecomposition { - fn decompose_contours(&self) -> Option>>; -} - -impl ContourDecomposition for IntContour { - fn decompose_contours(&self) -> Option>> { - if self.len() < 3 { - return None; - } - let mut id_points: Vec<_> = self - .iter() - .enumerate() - .map(|(i, &p)| IdPoint::new(i, p)) - .collect(); - - id_points.sort_unstable_by(|p0, p1| p0.point.cmp(&p1.point).then_with(|| p0.id.cmp(&p1.id))); - - let mut p0 = id_points.first().unwrap().point; - let mut anchors = Vec::new(); - let mut n = 0; - for (i, idp) in id_points.iter().enumerate().skip(1) { - if p0 == idp.point { - n += 1; - continue; - } - - if n > 0 { - anchors.extend_from_slice(&id_points[i - n - 1..i]); - n = 0; - } - - p0 = idp.point; - } - - if anchors.is_empty() { - return None; - } - - anchors.sort_by_key(|p0| p0.id); - - let mut contours = Vec::with_capacity((anchors.len() >> 1) + 1); - - let mut queue = vec![]; - - let mut i = 0; - while i < anchors.len() { - let a = anchors[i]; - let mut sub_path: SubPath = if let Some(sub_path) = queue.pop() { - sub_path - } else { - queue.push(SubPath::::start(a)); - i += 1; - continue; - }; - - if sub_path.node == a.point { - sub_path.join(a, self); - contours.push(sub_path.path); - if let Some(prev) = queue.last_mut() { - prev.shift(a); - } else { - queue.push(SubPath::::start(a)); - } - } else { - sub_path.join(a, self); - queue.push(sub_path); - queue.push(SubPath::::start(a)); - } - i += 1; - } - - let mut sub_path: SubPath = queue.pop().unwrap(); - - if sub_path.last < self.len() { - sub_path.path.extend_from_slice(&self[sub_path.last..]); - } - - let i0 = anchors.first().unwrap().id; - if i0 > 0 { - sub_path.path.extend_from_slice(&self[..i0]); - } - - contours.push(sub_path.path); - - Some(contours) - } -} - -#[cfg(test)] -mod tests { - use crate::core::divide::ContourDecomposition; - use alloc::vec; - use i_float::int::point::IntPoint; - use i_shape::int::shape::IntContour; - - #[test] - fn test_0() { - let origin = vec![ - IntPoint::new(0, 0), - IntPoint::new(0, 2), - IntPoint::new(2, 0), - IntPoint::new(4, 2), - IntPoint::new(4, 0), - IntPoint::new(2, 0), - ]; - - let contours = origin.decompose_contours().unwrap(); - - assert_eq!(contours.len(), 2); - } - - #[test] - fn test_0_rotate() { - let origin = vec![ - IntPoint::new(0, 0), - IntPoint::new(0, 2), - IntPoint::new(2, 0), - IntPoint::new(4, 2), - IntPoint::new(4, 0), - IntPoint::new(2, 0), - ]; - - for i in 0..origin.len() { - let contour = rotate(&origin, i); - let contours = contour.decompose_contours().unwrap(); - assert_eq!(contours.len(), 2); - assert_eq!(contours.iter().fold(0, |s, c| s + c.len()), origin.len()); - } - } - - #[test] - fn test_1_0() { - let origin = vec![ - IntPoint::new(0, 0), - IntPoint::new(-2, 2), - IntPoint::new(0, 2), - IntPoint::new(-2, 4), - IntPoint::new(0, 4), - IntPoint::new(-2, 6), - IntPoint::new(2, 6), - IntPoint::new(0, 4), - IntPoint::new(2, 4), - IntPoint::new(0, 2), - IntPoint::new(2, 2), - ]; - - let contours = origin.decompose_contours().unwrap(); - - assert_eq!(contours.len(), 3); - } - - #[test] - fn test_1_1() { - let origin = vec![ - IntPoint::new(-2, 4), - IntPoint::new(0, 4), - IntPoint::new(-2, 6), - IntPoint::new(2, 6), - IntPoint::new(0, 4), - IntPoint::new(2, 4), - IntPoint::new(0, 2), - IntPoint::new(2, 2), - IntPoint::new(0, 0), - IntPoint::new(-2, 2), - IntPoint::new(0, 2), - ]; - - let contours = origin.decompose_contours().unwrap(); - - assert_eq!(contours.len(), 3); - } - - #[test] - fn test_1_rotate() { - let origin = vec![ - IntPoint::new(0, 0), - IntPoint::new(-2, 2), - IntPoint::new(0, 2), - IntPoint::new(-2, 4), - IntPoint::new(0, 4), - IntPoint::new(-2, 6), - IntPoint::new(2, 6), - IntPoint::new(0, 4), - IntPoint::new(2, 4), - IntPoint::new(0, 2), - IntPoint::new(2, 2), - ]; - - let n = origin.len(); - for i in 0..n { - let contour = rotate(&origin, i); - let contours = contour.decompose_contours().unwrap(); - assert_eq!(contours.len(), 3); - let len = contours.iter().fold(0, |s, c| s + c.len()); - assert_eq!(len, n); - } - } - - #[test] - fn test_2() { - let origin = vec![ - IntPoint::new(0, 0), - IntPoint::new(-2, -1), - IntPoint::new(-2, 1), - IntPoint::new(0, 0), - IntPoint::new(-1, 2), - IntPoint::new(1, 2), - IntPoint::new(0, 0), - IntPoint::new(2, 1), - IntPoint::new(2, -1), - IntPoint::new(0, 0), - IntPoint::new(1, -2), - IntPoint::new(-1, -2), - ]; - - let contours = origin.decompose_contours().unwrap(); - assert_eq!(contours.len(), 4); - } - - #[test] - fn test_2_rotate() { - let origin = vec![ - IntPoint::new(0, 0), - IntPoint::new(-2, -1), - IntPoint::new(-2, 1), - IntPoint::new(0, 0), - IntPoint::new(-1, 2), - IntPoint::new(1, 2), - IntPoint::new(0, 0), - IntPoint::new(2, 1), - IntPoint::new(2, -1), - IntPoint::new(0, 0), - IntPoint::new(1, -2), - IntPoint::new(-1, -2), - ]; - - let n = origin.len(); - for i in 0..n { - let contour = rotate(&origin, i); - let contours = contour.decompose_contours().unwrap(); - assert_eq!(contours.len(), 4); - let len = contours.iter().fold(0, |s, c| s + c.len()); - assert_eq!(len, n); - } - } - - fn rotate(contour: &IntContour, s: usize) -> IntContour { - contour - .iter() - .cycle() - .skip(s) - .take(contour.len()) - .cloned() - .collect() - } -} diff --git a/iOverlay/src/core/mod.rs b/iOverlay/src/core/mod.rs index 5ff21ca7..64af2b8d 100644 --- a/iOverlay/src/core/mod.rs +++ b/iOverlay/src/core/mod.rs @@ -1,4 +1,3 @@ -pub mod divide; pub mod edge_data; pub mod edge_overlay; pub mod extract; From 56f2e7ff032468c4852d09c514faea2316f772af Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Wed, 9 Sep 2026 21:22:51 +0300 Subject: [PATCH 06/44] outline boundary tests --- iOverlay/tests/outline_boundary_tests.rs | 97 ++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 iOverlay/tests/outline_boundary_tests.rs diff --git a/iOverlay/tests/outline_boundary_tests.rs b/iOverlay/tests/outline_boundary_tests.rs new file mode 100644 index 00000000..32a03a48 --- /dev/null +++ b/iOverlay/tests/outline_boundary_tests.rs @@ -0,0 +1,97 @@ +use i_overlay::mesh::outline::offset::OutlineOffset; +use i_overlay::mesh::style::{LineJoin, OutlineStyle}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +// A broken subdivision count can make an outline loop effectively forever. +// Keep the regression bounded in both debug and release builds. +fn check_round_outline_in_subprocess(test_name: &str, angle: f64) { + const CHILD_ENV: &str = "I_OVERLAY_OUTLINE_BOUNDARY_TEST_CHILD"; + if std::env::var(CHILD_ENV).as_deref() == Ok(test_name) { + let square = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; + let style = OutlineStyle::new(1.0).line_join(LineJoin::Round(angle)); + let result = square.outline(&style); + assert_eq!(result.len(), 1); + assert!( + result + .iter() + .flatten() + .flatten() + .all(|p| p[0].is_finite() && p[1].is_finite()) + ); + return; + } + + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", test_name, "--nocapture"]) + .env(CHILD_ENV, test_name) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + loop { + if child.try_wait().unwrap().is_some() { + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "outline with round angle {angle} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + return; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!( + "outline of a four-vertex square with round angle {angle} did not finish within 3 seconds" + ); + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn hypothesis_outline_zero_round_angle_terminates() { + check_round_outline_in_subprocess("hypothesis_outline_zero_round_angle_terminates", 0.0); +} + +#[test] +fn hypothesis_outline_tiny_positive_round_angle_terminates() { + check_round_outline_in_subprocess("hypothesis_outline_tiny_positive_round_angle_terminates", 1e-20); +} + +#[test] +fn outline_normal_round_angle_control() { + check_round_outline_in_subprocess("outline_normal_round_angle_control", 0.1); +} + +fn square(lo: f64, hi: f64, clockwise: bool) -> Vec<[f64; 2]> { + let mut path = vec![[lo, lo], [hi, lo], [hi, hi], [lo, hi]]; + if clockwise { + path.reverse(); + } + path +} + +#[test] +fn hypothesis_collapsed_hole_does_not_erase_other_holes() { + let outer = square(0.0, 30.0, false); + let collapsed_hole = square(3.0, 5.0, true); + let surviving_hole = square(10.0, 20.0, true); + let style = OutlineStyle::new(1.0); + + let expected = vec![outer.clone(), surviving_hole.clone()] + .outline_fixed_scale(&style, 100.0) + .unwrap(); + assert_eq!(expected.len(), 1); + assert_eq!(expected[0].len(), 2, "control must retain the large hole"); + + let actual = vec![outer, collapsed_hole, surviving_hole] + .outline_fixed_scale(&style, 100.0) + .unwrap(); + assert_eq!( + actual, expected, + "a collapsed hole must not change any other contour" + ); +} From 51c34bb1091e3e4b7044a384685f6df719bf839c Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Thu, 10 Sep 2026 10:02:13 +0300 Subject: [PATCH 07/44] fix area condition in string split --- iOverlay/src/string/split.rs | 4 +- iOverlay/tests/slice_area_tests.rs | 115 +++++++++++++++++++++++ iOverlay/tests/stroke_direction_tests.rs | 52 ++++++++++ 3 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 iOverlay/tests/slice_area_tests.rs create mode 100644 iOverlay/tests/stroke_direction_tests.rs diff --git a/iOverlay/src/string/split.rs b/iOverlay/src/string/split.rs index 90d62fef..a1dcee3f 100644 --- a/iOverlay/src/string/split.rs +++ b/iOverlay/src/string/split.rs @@ -55,7 +55,7 @@ impl Split for IntContour { } } - if contour_buffer.len() > 2 { + if contour_buffer.len() > 2 && contour_buffer.validate_area(min_area) { result.push(contour_buffer.as_slice().to_vec()); } @@ -171,7 +171,7 @@ impl ValidateArea for IntContour { return true; } let abs_area = self.unsafe_area().unsigned_abs() >> 1; - abs_area < min_area + abs_area >= min_area } } diff --git a/iOverlay/tests/slice_area_tests.rs b/iOverlay/tests/slice_area_tests.rs new file mode 100644 index 00000000..f4715fea --- /dev/null +++ b/iOverlay/tests/slice_area_tests.rs @@ -0,0 +1,115 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::IntOverlayOptions; +use i_overlay::float::overlay::OverlayOptions; +use i_overlay::float::string_overlay::FloatStringOverlay; +use i_overlay::string::overlay::StringOverlay; +use i_overlay::string::rule::StringRule; + +fn square() -> [IntPoint; 4] { + [ + IntPoint::new(0, 0), + IntPoint::new(10, 0), + IntPoint::new(10, 10), + IntPoint::new(0, 10), + ] +} + +fn area_two(path: &[IntPoint]) -> i64 { + path.iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| i64::from(a.x) * i64::from(b.y) - i64::from(a.y) * i64::from(b.x)) + .sum() +} + +#[test] +fn slice_minimum_area_removes_small_piece() { + let mut overlay = StringOverlay::with_shape_contour(&square()); + overlay.add_string_line([IntPoint::new(1, -1), IntPoint::new(1, 11)]); + let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); + let unfiltered = graph.extract_shapes(StringRule::Slice); + let mut areas: Vec<_> = unfiltered.iter().map(|s| area_two(&s[0])).collect(); + areas.sort(); + assert_eq!(areas, [20, 180]); + + for (threshold, expected) in [ + (0, vec![20, 180]), + (10, vec![20, 180]), + (11, vec![180]), + (50, vec![180]), + (90, vec![180]), + (91, vec![]), + ] { + let mut options = IntOverlayOptions::default(); + options.min_output_area = threshold; + let filtered = graph.extract_shapes_custom(StringRule::Slice, options); + let mut areas: Vec<_> = filtered.iter().map(|s| area_two(&s[0])).collect(); + areas.sort(); + assert_eq!(areas, expected, "minimum area {threshold} must include equality"); + } +} + +#[test] +fn slice_minimum_area_above_subject_area_returns_empty() { + let mut overlay = StringOverlay::with_shape_contour(&square()); + let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); + let mut options = IntOverlayOptions::default(); + options.min_output_area = 1000; + let result = graph.extract_shapes_custom(StringRule::Slice, options); + assert!( + result.is_empty(), + "a square of area 100 must be filtered out at threshold 1000" + ); +} + +#[test] +fn slice_small_area_threshold_preserves_large_loops_and_holes() { + let mut overlay = StringOverlay::with_shape_contour(&square()); + overlay.add_string_path(&[ + IntPoint::new(0, 0), + IntPoint::new(3, 3), + IntPoint::new(7, 3), + IntPoint::new(7, 7), + IntPoint::new(3, 7), + IntPoint::new(3, 3), + ]); + let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); + let expected = graph.extract_shapes(StringRule::Slice); + assert_eq!(expected.len(), 2); + let total_area: i64 = expected.iter().flatten().map(|p| area_two(p)).sum(); + assert_eq!(total_area, 200); + for threshold in [1, 16] { + let mut options = IntOverlayOptions::default(); + options.min_output_area = threshold; + let actual = graph.extract_shapes_custom(StringRule::Slice, options); + assert_eq!( + actual, expected, + "areas 16 and 84 must survive minimum area {threshold}" + ); + } +} + +#[test] +fn float_slice_small_area_threshold_preserves_large_loops_and_holes() { + let subject = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; + let cut = [ + [0.0, 0.0], + [3.0, 3.0], + [7.0, 3.0], + [7.0, 7.0], + [3.0, 7.0], + [3.0, 3.0], + ]; + let mut overlay = FloatStringOverlay::with_shape_and_string_fixed_scale(&subject, &cut, 100.0).unwrap(); + let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); + let expected = graph.extract_shapes(StringRule::Slice); + assert_eq!(expected.len(), 2); + assert_eq!(expected.iter().map(|s| s.len() - 1).sum::(), 1); + let mut options = OverlayOptions::default(); + options.min_output_area = 1.0; + let actual = graph.extract_shapes_custom(StringRule::Slice, options); + assert_eq!( + actual, expected, + "a low area threshold must preserve the annulus and its inner piece" + ); +} diff --git a/iOverlay/tests/stroke_direction_tests.rs b/iOverlay/tests/stroke_direction_tests.rs new file mode 100644 index 00000000..81930d7d --- /dev/null +++ b/iOverlay/tests/stroke_direction_tests.rs @@ -0,0 +1,52 @@ +use i_overlay::mesh::stroke::offset::StrokeOffset; +use i_overlay::mesh::style::{LineJoin, StrokeStyle}; + +fn canonical(shapes: Vec>>) -> Vec>> { + let mut shapes: Vec>> = shapes + .into_iter() + .map(|shape| { + let mut shape: Vec> = shape + .into_iter() + .map(|path| { + let mut path: Vec<_> = path + .into_iter() + .map(|p| [(p[0] * 1e6).round() as i64, (p[1] * 1e6).round() as i64]) + .collect(); + let start = path.iter().enumerate().min_by_key(|(_, p)| **p).unwrap().0; + path.rotate_left(start); + path + }) + .collect(); + shape[1..].sort(); + shape + }) + .collect(); + shapes.sort(); + shapes +} + +#[test] +fn reversing_path_preserves_bevel_and_miter_strokes() { + let mut seed = 0x5493_baa7_9215_7831_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 21) as f64 - 10.0 + }; + for case in 0..600 { + let path: Vec<_> = (0..3 + case % 7).map(|_| [next(), next()]).collect(); + let mut reversed = path.clone(); + reversed.reverse(); + for closed in [false, true] { + for join in [LineJoin::Bevel, LineJoin::Miter(0.2)] { + let style = StrokeStyle::new(1.25).line_join(join.clone()); + let forward = path.stroke_fixed_scale(style.clone(), closed, 1000.0).unwrap(); + let backward = reversed.stroke_fixed_scale(style, closed, 1000.0).unwrap(); + assert_eq!( + canonical(forward), + canonical(backward), + "case={case}, closed={closed}, join={join:?}, path={path:?}" + ); + } + } + } +} From d550223b9559648a8869cc8da6bba9ecb02a0e95 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Thu, 10 Sep 2026 10:39:13 +0300 Subject: [PATCH 08/44] add zero length segments fix and tests cap start/end cases --- iOverlay/src/mesh/outline/builder.rs | 32 +++++ iOverlay/src/mesh/stroke/builder.rs | 51 ++++++++ iOverlay/src/mesh/stroke/builder_cap.rs | 54 ++++++++- iOverlay/src/mesh/subject.rs | 4 + iOverlay/src/mesh/variable_stroke/builder.rs | 24 ++++ iOverlay/src/segm/build.rs | 30 +++++ iOverlay/src/segm/segment.rs | 1 + iOverlay/src/split/solver.rs | 50 ++++++++ iOverlay/tests/slice_partition_tests.rs | 116 +++++++++++++++++++ iOverlay/tests/stroke_cap_precision_tests.rs | 89 ++++++++++++++ 10 files changed, 447 insertions(+), 4 deletions(-) create mode 100644 iOverlay/tests/slice_partition_tests.rs create mode 100644 iOverlay/tests/stroke_cap_precision_tests.rs diff --git a/iOverlay/src/mesh/outline/builder.rs b/iOverlay/src/mesh/outline/builder.rs index 5262a812..3272a4dc 100644 --- a/iOverlay/src/mesh/outline/builder.rs +++ b/iOverlay/src/mesh/outline/builder.rs @@ -241,3 +241,35 @@ mod tests { assert!(!segments.is_empty()); } } + +#[cfg(test)] +mod non_degenerate_tests { + use super::*; + use i_float::float::rect::FloatRect; + + #[test] + fn outline_styles_skip_coincident_integer_endpoints() { + let adapter = + FloatPointAdapter::<[f64; 2], i32>::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 1.0); + for radius in [-2.0, -0.1, 0.0, 0.1, 2.0] { + for join in [ + LineJoin::Bevel, + LineJoin::Miter(0.1), + LineJoin::Miter(3.0), + LineJoin::Round(0.01), + ] { + let builder = OutlineBuilder::new(radius, &join); + for end in [[10.0, 10.0], [0.0, 0.0], [20.0, 0.1]] { + let path = [[0.0, 0.0], [0.0, 0.0], [0.01, 0.01], [10.0, 0.0], end]; + let mut segments = Vec::new(); + builder.build(&path, &adapter, &mut segments); + assert!(!segments.is_empty()); + assert!( + segments.iter().all(|s| s.x_segment.a < s.x_segment.b), + "radius={radius}, join={join:?}, end={end:?}" + ); + } + } + } + } +} diff --git a/iOverlay/src/mesh/stroke/builder.rs b/iOverlay/src/mesh/stroke/builder.rs index b719023f..c124f2cf 100644 --- a/iOverlay/src/mesh/stroke/builder.rs +++ b/iOverlay/src/mesh/stroke/builder.rs @@ -255,3 +255,54 @@ mod tests { assert!(!segments.is_empty()); } } + +#[cfg(test)] +mod non_degenerate_tests { + use super::*; + use crate::mesh::style::LineCap; + use alloc::rc::Rc; + use i_float::float::rect::FloatRect; + + #[test] + fn stroke_styles_skip_coincident_integer_endpoints() { + let adapter = + FloatPointAdapter::<[f64; 2], i32>::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 1.0); + let caps = [ + LineCap::Butt, + LineCap::Square, + LineCap::Round(0.01), + LineCap::Custom(Rc::from([[0.0, -1.0], [0.0, -1.0], [0.01, -1.0], [0.0, 1.0]])), + ]; + for width in [0.0, 0.1, 1.0, 4.0] { + for join in [ + LineJoin::Bevel, + LineJoin::Miter(0.1), + LineJoin::Miter(3.0), + LineJoin::Round(0.01), + ] { + for cap in &caps { + let builder = StrokeBuilder::new( + StrokeStyle::new(width) + .line_join(join.clone()) + .start_cap(cap.clone()) + .end_cap(cap.clone()), + ); + for end in [[10.0, 10.0], [0.0, 0.0], [20.0, 0.1]] { + let path = [[0.0, 0.0], [0.0, 0.0], [0.01, 0.01], [10.0, 0.0], end]; + for closed in [false, true] { + let mut segments = Vec::new(); + builder.build(&path, closed, &adapter, &mut segments); + assert!( + segments.iter().all(|s| s.x_segment.a < s.x_segment.b), + "width={width}, join={join:?}, cap={cap:?}, closed={closed}, end={end:?}" + ); + if width == 4.0 { + assert!(!segments.is_empty()); + } + } + } + } + } + } + } +} diff --git a/iOverlay/src/mesh/stroke/builder_cap.rs b/iOverlay/src/mesh/stroke/builder_cap.rs index 14cf9145..5f4176a8 100644 --- a/iOverlay/src/mesh/stroke/builder_cap.rs +++ b/iOverlay/src/mesh/stroke/builder_cap.rs @@ -79,12 +79,17 @@ impl CapBuilder

{ let r = rotator.rotate(p); let q = FloatPointMath::add(&r, §ion.a); let b = adapter.float_to_int(&q); - segments.push(Segment::subject(a, b)); + // Repeated template points and rounding can collapse a cap edge. + if a != b { + segments.push(Segment::subject(a, b)); + } a = b; } } let last = adapter.float_to_int(§ion.a_bot); - segments.push(Segment::subject(a, last)); + if a != last { + segments.push(Segment::subject(a, last)); + } } pub(super) fn add_to_end( @@ -100,12 +105,16 @@ impl CapBuilder

{ let r = rotator.rotate(p); let q = FloatPointMath::add(&r, §ion.b); let b = adapter.float_to_int(&q); - segments.push(Segment::subject(a, b)); + if a != b { + segments.push(Segment::subject(a, b)); + } a = b; } } let last = adapter.float_to_int(§ion.b_top); - segments.push(Segment::subject(a, last)); + if a != last { + segments.push(Segment::subject(a, last)); + } } #[inline] @@ -130,3 +139,40 @@ impl CapBuilder

{ } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::fill_rule::FillRule; + use crate::core::overlay::Overlay; + use crate::core::overlay_rule::OverlayRule; + use crate::mesh::stroke::section::SectionToSegment; + use i_float::float::rect::FloatRect; + + #[test] + fn round_caps_do_not_emit_collapsed_edges() { + let adapter = FloatPointAdapter::<[f64; 2], i32>::with_scale( + FloatRect { + min_x: -3.0, + max_x: 13.0, + min_y: -3.0, + max_y: 3.0, + }, + 1.0, + ); + let section = Section::new(2.0, &[0.0, 0.0], &[10.0, 0.0]); + let cap = CapBuilder::new(LineCap::Round(0.01 * PI), 2.0); + let mut segments = Vec::new(); + segments.add_section(§ion, &adapter); + cap.add_to_start(§ion, &adapter, &mut segments); + cap.add_to_end(§ion, &adapter, &mut segments); + + let collapsed = segments.iter().filter(|s| s.x_segment.a == s.x_segment.b).count(); + assert_eq!( + collapsed, 0, + "cap construction must skip coincident integer endpoints" + ); + let output = Overlay::with_segments(segments).overlay(OverlayRule::Subject, FillRule::Positive); + assert_eq!(output.len(), 1); + } +} diff --git a/iOverlay/src/mesh/subject.rs b/iOverlay/src/mesh/subject.rs index 3cde1bcf..7e3f1793 100644 --- a/iOverlay/src/mesh/subject.rs +++ b/iOverlay/src/mesh/subject.rs @@ -7,6 +7,10 @@ use i_float::int::point::IntPoint; impl Segment { #[inline] pub(crate) fn subject(p0: IntPoint, p1: IntPoint) -> Self { + debug_assert!( + p0 != p1, + "zero-length edges must be filtered before construction" + ); if p0 < p1 { Self { x_segment: XSegment { a: p0, b: p1 }, diff --git a/iOverlay/src/mesh/variable_stroke/builder.rs b/iOverlay/src/mesh/variable_stroke/builder.rs index 712dd5bd..d8b00f68 100644 --- a/iOverlay/src/mesh/variable_stroke/builder.rs +++ b/iOverlay/src/mesh/variable_stroke/builder.rs @@ -602,6 +602,30 @@ mod tests { FloatPointAdapter::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 1.0) } + #[test] + fn variable_width_strokes_skip_coincident_integer_endpoints() { + for width in [0.0, 0.1, 1.0, 4.0] { + for end in [[10.0, 10.0], [0.0, 0.0], [20.0, 0.1]] { + let path = [ + StrokeVertex::new([0.0, 0.0], width), + StrokeVertex::new([0.01, 0.01], width), + StrokeVertex::new([10.0, 0.0], width * 2.0), + StrokeVertex::new(end, width), + ]; + let builder = VariableStrokeBuilder::new(VariableStrokeStyle::new().round_angle(0.01)); + let mut segments = Vec::>::new(); + builder.build(&path, &adapter(), &mut segments); + assert!( + segments.iter().all(|s| s.x_segment.a < s.x_segment.b), + "width={width}, end={end:?}" + ); + if width == 4.0 { + assert!(!segments.is_empty()); + } + } + } + } + #[test] fn empty_path_does_not_create_subsegments_or_edges() { let path: [StrokeVertex<[f64; 2]>; 0] = []; diff --git a/iOverlay/src/segm/build.rs b/iOverlay/src/segm/build.rs index 5293fbe2..965c7390 100644 --- a/iOverlay/src/segm/build.rs +++ b/iOverlay/src/segm/build.rs @@ -129,6 +129,10 @@ impl PointFilter for DropCollinear { impl Segment { #[inline] pub(crate) fn with_ab(p0: IntPoint, p1: IntPoint, direct: C, invert: C) -> Self { + debug_assert!( + p0 != p1, + "zero-length edges must be filtered before construction" + ); if p0 < p1 { Self { x_segment: XSegment { a: p0, b: p1 }, @@ -155,6 +159,32 @@ mod tests { use alloc::vec::Vec; use i_float::int::point::IntPoint; + #[test] + fn repeated_path_points_do_not_create_zero_length_segments() { + let points = [ + IntPoint::new(0, 0), + IntPoint::new(1, 0), + IntPoint::new(2, 0), + IntPoint::new(1, 1), + ]; + // All six-point paths on this grid include duplicates or collinear runs. + for mut code in 0..4_usize.pow(6) { + let path: [_; 6] = core::array::from_fn(|_| { + let p = points[code % 4]; + code /= 4; + p + }); + for keep in [false, true] { + let mut segments = Vec::>::new(); + segments.append_path_iter(path.into_iter(), ShapeType::Subject, keep); + assert!( + segments.iter().all(|s| s.x_segment.a < s.x_segment.b), + "path={path:?}, keep={keep}" + ); + } + } + } + #[test] fn test_0() { let points = [ diff --git a/iOverlay/src/segm/segment.rs b/iOverlay/src/segm/segment.rs index 1c0f6883..ba44a11e 100644 --- a/iOverlay/src/segm/segment.rs +++ b/iOverlay/src/segm/segment.rs @@ -39,6 +39,7 @@ impl> Segment { data: D, store: &mut D::Store, ) -> Self { + debug_assert!(a != b, "split points must differ from the segment endpoints"); if a < b { Self { x_segment: XSegment { a, b }, diff --git a/iOverlay/src/split/solver.rs b/iOverlay/src/split/solver.rs index e774a90f..16ed5b1a 100644 --- a/iOverlay/src/split/solver.rs +++ b/iOverlay/src/split/solver.rs @@ -301,3 +301,53 @@ where } } } + +#[cfg(test)] +mod non_degenerate_tests { + use super::*; + use crate::segm::boolean::ShapeCountBoolean; + use i_float::int::point::IntPoint; + + #[test] + fn grid_intersections_do_not_create_zero_length_segments() { + let points: Vec<_> = (-2..=2) + .flat_map(|x| (-2..=2).map(move |y| IntPoint::new(x, y))) + .collect(); + let mut edges = Vec::new(); + for (i, &a) in points.iter().enumerate() { + for &b in &points[i + 1..] { + edges.push(Segment::::subject(a, b)); + } + } + let mut splitter = SplitSolver::new(); + let mut buffer = Vec::new(); + for (i, &a) in edges.iter().enumerate() { + for &b in &edges[i + 1..] { + for radius_squared in [1, 2, 4, 16] { + splitter.marks.clear(); + SplitSolver::cross( + 0, + 1, + &a.x_segment, + &b.x_segment, + &mut splitter.marks, + radius_squared, + ); + let mut segments = alloc::vec![a, b]; + for mark in &splitter.marks { + let edge = segments[mark.index].x_segment; + assert!( + mark.point != edge.a && mark.point != edge.b, + "endpoint split: {edge:?}, {:?}", + mark.point + ); + } + // Multiple intersections can produce the same mark. + splitter.marks.extend_from_within(..); + splitter.apply(&mut segments, &mut buffer, &Solver::LIST, &mut ()); + assert!(segments.iter().all(|s| s.x_segment.a < s.x_segment.b)); + } + } + } + } +} diff --git a/iOverlay/tests/slice_partition_tests.rs b/iOverlay/tests/slice_partition_tests.rs new file mode 100644 index 00000000..14671154 --- /dev/null +++ b/iOverlay/tests/slice_partition_tests.rs @@ -0,0 +1,116 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::string::overlay::StringOverlay; +use i_overlay::string::rule::StringRule; + +fn area_two(path: &[IntPoint]) -> i64 { + path.iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| i64::from(a.x) * i64::from(b.y) - i64::from(a.y) * i64::from(b.x)) + .sum() +} + +// Independent ray-crossing oracle. Samples have odd coordinates; cut edges +// lie on even grid lines, so no sample lies on an input boundary. +fn contains(path: &[IntPoint], p: IntPoint) -> bool { + let mut inside = false; + for (a, b) in path.iter().zip(path.iter().cycle().skip(1)) { + if (a.y > p.y) != (b.y > p.y) { + let cross = + i64::from(b.x - a.x) * i64::from(p.y - a.y) - i64::from(b.y - a.y) * i64::from(p.x - a.x); + if (cross > 0) == (b.y > a.y) { + inside = !inside; + } + } + } + inside +} + +#[test] +fn grid_cuts_partition_subject_without_gaps_or_overlaps() { + let subject = [ + IntPoint::new(0, 0), + IntPoint::new(20, 0), + IntPoint::new(20, 20), + IntPoint::new(0, 20), + ]; + let mut seed = 0x7e59_1928_f17a_57b3_u64; + for case in 0..512 { + let mut cuts = Vec::new(); + for x in (0..=20).step_by(2) { + for y in (0..=20).step_by(2) { + for (dx, dy) in [(2, 0), (0, 2)] { + if x + dx > 20 || y + dy > 20 { + continue; + } + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + if (seed >> 32) % 100 < 30 + case % 60 { + cuts.push([IntPoint::new(x, y), IntPoint::new(x + dx, y + dy)]); + } + } + } + } + let mut overlay = StringOverlay::with_shape_contour(&subject); + overlay.add_string_lines(&cuts); + let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); + let result = graph.extract_shapes(StringRule::Slice); + let total: i64 = result.iter().flatten().map(|path| area_two(path)).sum(); + assert_eq!(total, 800, "case={case}, cuts={cuts:?}, result={result:?}"); + for x in (1..20).step_by(2) { + for y in (1..20).step_by(2) { + let p = IntPoint::new(x, y); + let count = result + .iter() + .filter(|shape| { + contains(&shape[0], p) && !shape[1..].iter().any(|hole| contains(hole, p)) + }) + .count(); + assert_eq!( + count, 1, + "case={case}, sample={p:?}, cuts={cuts:?}, result={result:?}" + ); + } + } + } +} + +#[test] +fn interior_self_crossing_cuts_preserve_subject_area() { + let subject = [ + IntPoint::new(0, 0), + IntPoint::new(100, 0), + IntPoint::new(100, 100), + IntPoint::new(0, 100), + ]; + let mut seed = 0x61f4_2ce8_290b_5b83_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 91) as i32 + 5 + }; + for case in 0..3000 { + let mut cut: Vec<_> = (0..3 + case % 25) + .map(|_| IntPoint::new(next(), next())) + .collect(); + if case % 2 == 0 { + cut.push(cut[0]); + } + let mut overlay = StringOverlay::with_shape_contour(&subject); + overlay.add_string_path(&cut); + let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); + let result = graph.extract_shapes(StringRule::Slice); + let total: i64 = result.iter().flatten().map(|path| area_two(path)).sum(); + assert_eq!(total, 20000, "case={case}, cut={cut:?}, result={result:?}"); + for shape in &result { + assert!( + area_two(&shape[0]) > 0, + "invalid outer: case={case}, cut={cut:?}, shape={shape:?}" + ); + for hole in &shape[1..] { + assert!( + area_two(hole) < 0, + "invalid hole: case={case}, cut={cut:?}, hole={hole:?}" + ); + } + } + } +} diff --git a/iOverlay/tests/stroke_cap_precision_tests.rs b/iOverlay/tests/stroke_cap_precision_tests.rs new file mode 100644 index 00000000..75767de1 --- /dev/null +++ b/iOverlay/tests/stroke_cap_precision_tests.rs @@ -0,0 +1,89 @@ +use i_overlay::float::overlay::OverlayOptions; +use i_overlay::mesh::stroke::offset::StrokeOffset; +use i_overlay::mesh::style::{LineCap, StrokeStyle}; + +#[test] +fn round_caps_survive_coarse_integer_grid() { + let path = [[0.0, 0.0], [10.0, 0.0]]; + let style = StrokeStyle::new(4.0) + .start_cap(LineCap::Round(0.01)) + .end_cap(LineCap::Round(0.01)); + let output = path.stroke_fixed_scale(style, false, 1.0).unwrap(); + assert_eq!(output.len(), 1); + assert_eq!(output[0].len(), 1); + assert!(output[0][0].len() >= 4); +} + +#[test] +fn round_caps_survive_coarse_grid_without_cleanup() { + let path = [[0.0, 0.0], [10.0, 0.0]]; + let style = StrokeStyle::new(4.0) + .start_cap(LineCap::Round(0.01)) + .end_cap(LineCap::Round(0.01)); + let mut options = OverlayOptions::default(); + options.clean_result = false; + let output = path + .stroke_custom_fixed_scale(style, false, options, 1.0) + .unwrap(); + assert_eq!(output.len(), 1); +} + +#[test] +fn coarse_butt_and_square_caps_and_fine_round_caps_control() { + let path = [[0.0, 0.0], [10.0, 0.0]]; + for (cap, scale) in [ + (LineCap::Butt, 1.0), + (LineCap::Square, 1.0), + (LineCap::Round(0.01), 100.0), + ] { + let style = StrokeStyle::new(4.0).start_cap(cap.clone()).end_cap(cap.clone()); + let output = path.stroke_fixed_scale(style, false, scale).unwrap(); + assert_eq!(output.len(), 1, "cap={cap:?}, scale={scale}"); + } +} + +#[test] +fn custom_cap_without_repeated_points_control() { + let path = [[0.0, 0.0], [10.0, 0.0]]; + let cap = LineCap::Custom(std::rc::Rc::from([[1.0, -1.0], [1.0, 1.0]])); + let style = StrokeStyle::new(4.0).start_cap(cap.clone()).end_cap(cap); + let output = path.stroke(style, false); + assert_eq!(output.len(), 1); +} + +#[test] +fn repeated_custom_cap_points_preserve_stroke() { + const TEST_NAME: &str = "repeated_custom_cap_points_preserve_stroke"; + const CHILD_ENV: &str = "I_OVERLAY_REPEATED_CAP_TEST_CHILD"; + if std::env::var_os(CHILD_ENV).is_none() { + // A malformed graph may never finish extracting a contour. Keep this + // regression bounded even when the underlying bug is present. + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", TEST_NAME, "--nocapture"]) + .env(CHILD_ENV, "1") + .spawn() + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + loop { + if let Some(status) = child.try_wait().unwrap() { + assert!(status.success(), "custom cap child failed: {status}"); + return; + } + if std::time::Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("a two-point stroke with a repeated custom cap point did not finish within 3 seconds"); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + let path = [[0.0, 0.0], [10.0, 0.0]]; + let cap = LineCap::Custom(std::rc::Rc::from([[1.0, -1.0], [1.0, -1.0], [1.0, 1.0]])); + let style = StrokeStyle::new(4.0).start_cap(cap.clone()).end_cap(cap); + let output = path.stroke(style, false); + assert_eq!( + output.len(), + 1, + "duplicating a cap template point must not erase a stroke" + ); +} From 2ebb5a1053f6a33cc50bef8ee00347d1dd2e4764 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Thu, 10 Sep 2026 10:54:06 +0300 Subject: [PATCH 09/44] clamp acos --- iOverlay/src/mesh/outline/builder_join.rs | 3 +- iOverlay/src/mesh/stroke/builder_join.rs | 3 +- iOverlay/tests/stroke_coverage_tests.rs | 126 ++++++++++++++++++++++ 3 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 iOverlay/tests/stroke_coverage_tests.rs diff --git a/iOverlay/src/mesh/outline/builder_join.rs b/iOverlay/src/mesh/outline/builder_join.rs index 5ffbf2c6..2dac64e7 100644 --- a/iOverlay/src/mesh/outline/builder_join.rs +++ b/iOverlay/src/mesh/outline/builder_join.rs @@ -207,7 +207,8 @@ impl JoinBuilder for RoundJoinBuild return; } - let angle = dot_product.acos(); + // Normalization can round the dot product outside [-1, 1], making acos return NaN. + let angle = dot_product.max(-P::Scalar::ONE).min(P::Scalar::ONE).acos(); let n = (angle * self.inv_ratio).to_usize(); let delta_angle = angle / P::Scalar::from_usize(n); diff --git a/iOverlay/src/mesh/stroke/builder_join.rs b/iOverlay/src/mesh/stroke/builder_join.rs index 14ff729e..8a83a6fd 100644 --- a/iOverlay/src/mesh/stroke/builder_join.rs +++ b/iOverlay/src/mesh/stroke/builder_join.rs @@ -253,7 +253,8 @@ impl JoinBuilder for RoundJoinBuild return; } - let angle = dot_product.acos(); + // Normalization can round the dot product outside [-1, 1], making acos return NaN. + let angle = dot_product.max(-P::Scalar::ONE).min(P::Scalar::ONE).acos(); let n = (angle * self.inv_ratio).to_usize(); let delta_angle = angle / P::Scalar::from_usize(n); diff --git a/iOverlay/tests/stroke_coverage_tests.rs b/iOverlay/tests/stroke_coverage_tests.rs new file mode 100644 index 00000000..c7d444d8 --- /dev/null +++ b/iOverlay/tests/stroke_coverage_tests.rs @@ -0,0 +1,126 @@ +use i_overlay::mesh::outline::offset::OutlineOffset; +use i_overlay::mesh::stroke::offset::StrokeOffset; +use i_overlay::mesh::style::OutlineStyle; +use i_overlay::mesh::style::{LineCap, LineJoin, StrokeStyle}; +use i_overlay::mesh::variable_stroke::offset::VariableStrokeOffset; +use i_overlay::mesh::variable_stroke::{StrokeVertex, VariableStrokeStyle}; + +fn contains(shapes: &[Vec>], p: [f64; 2]) -> bool { + shapes.iter().any(|shape| { + let mut inside = false; + for contour in shape { + let mut a = contour[contour.len() - 1]; + for &b in contour { + if (a[1] > p[1]) != (b[1] > p[1]) { + let x = a[0] + (p[1] - a[1]) * (b[0] - a[0]) / (b[1] - a[1]); + if p[0] < x { + inside = !inside; + } + } + a = b; + } + } + inside + }) +} + +fn assert_vertex_disks_covered(shapes: &[Vec>], path: &[StrokeVertex<[f64; 2]>], case: usize) { + for (index, vertex) in path.iter().enumerate() { + for sample in 0..16 { + let angle = sample as f64 * core::f64::consts::TAU / 16.0; + // Stay well inside the disk to exclude tessellation and grid error. + let radius = 0.4 * vertex.width; + let point = [ + vertex.point[0] + radius * angle.cos(), + vertex.point[1] + radius * angle.sin(), + ]; + assert!( + contains(shapes, point), + "uncovered disk: case={case}, vertex={index}, sample={sample}, point={point:?}, path={path:?}" + ); + } + } +} + +#[test] +fn round_strokes_cover_vertex_disks() { + let mut seed = 0x37a2_b951_d477_1011_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 21) as f64 - 10.0 + }; + for case in 0..2000 { + let path: Vec<_> = (0..3 + case % 8).map(|_| [next(), next()]).collect(); + let style = StrokeStyle::new(4.0) + .line_join(LineJoin::Round(0.05)) + .start_cap(LineCap::Round(0.05)) + .end_cap(LineCap::Round(0.05)); + let shapes = path.stroke_fixed_scale(style, false, 10000.0).unwrap(); + let vertices: Vec<_> = path.iter().map(|&p| StrokeVertex::new(p, 4.0)).collect(); + assert_vertex_disks_covered(&shapes, &vertices, case); + } +} + +#[test] +fn round_join_covers_a_reversing_vertex() { + let path = [[0.0, 0.0], [10.0, 0.0], [0.0, 0.0]]; + let style = StrokeStyle::new(4.0) + .line_join(LineJoin::Round(0.05)) + .start_cap(LineCap::Round(0.05)) + .end_cap(LineCap::Round(0.05)); + let shapes = path.stroke_fixed_scale(style, false, 10000.0).unwrap(); + assert!( + contains(&shapes, [11.5, 0.0]), + "round join must cover the turn at x=10" + ); +} + +#[test] +fn round_join_covers_a_diagonal_reversing_vertex() { + let path = [[0.0, 0.0], [6.0, -8.0], [0.0, 0.0]]; + let style = StrokeStyle::new(4.0) + .line_join(LineJoin::Round(0.05)) + .start_cap(LineCap::Round(0.05)) + .end_cap(LineCap::Round(0.05)); + let shapes = path.stroke_fixed_scale(style, false, 10000.0).unwrap(); + assert!( + contains(&shapes, [6.9, -9.2]), + "round join must cover the diagonal turn" + ); +} + +#[test] +fn round_outline_covers_a_diagonal_spike() { + let path = [ + [0.0, 0.0], + [0.0, 10.0], + [-10.0, 10.0], + [-10.0, 0.0], + [0.0, 0.0], + [6.0, -8.0], + ]; + let style = OutlineStyle::new(2.0).line_join(LineJoin::Round(0.05)); + let shapes = path.outline_fixed_scale(&style, 10000.0).unwrap(); + assert!( + contains(&shapes, [6.9, -9.2]), + "round outline must cover the diagonal tip" + ); +} + +#[test] +fn variable_strokes_cover_vertex_disks() { + let mut seed = 0x37a2_b951_d477_1011_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 21) as f64 - 10.0 + }; + for case in 0..2000 { + let path: Vec<_> = (0..3 + case % 8) + .map(|_| StrokeVertex::new([next(), next()], next() + 11.0)) + .collect(); + let shapes = path + .variable_stroke_fixed_scale(VariableStrokeStyle::new().round_angle(0.05), 10000.0) + .unwrap(); + assert_vertex_disks_covered(&shapes, &path, case); + } +} From 9df90dedb3bd531518ba27c71f7824ca0d651be1 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Thu, 10 Sep 2026 12:34:51 +0300 Subject: [PATCH 10/44] fix ogc set param direction --- iOverlay/src/core/extract_ogc.rs | 5 +- iOverlay/tests/integer_grid_oracle_tests.rs | 170 ++++++++++++++++++++ iOverlay/tests/integer_invariance_tests.rs | 97 +++++++++++ iOverlay/tests/mesh_scale_tests.rs | 141 ++++++++++++++++ iOverlay/tests/ogc_winding_tests.rs | 79 +++++++++ 5 files changed, 490 insertions(+), 2 deletions(-) create mode 100644 iOverlay/tests/integer_grid_oracle_tests.rs create mode 100644 iOverlay/tests/integer_invariance_tests.rs create mode 100644 iOverlay/tests/mesh_scale_tests.rs create mode 100644 iOverlay/tests/ogc_winding_tests.rs diff --git a/iOverlay/src/core/extract_ogc.rs b/iOverlay/src/core/extract_ogc.rs index e9f6b738..a4df95da 100644 --- a/iOverlay/src/core/extract_ogc.rs +++ b/iOverlay/src/core/extract_ogc.rs @@ -318,10 +318,11 @@ where // Self-touch splits can only produce holes inside this contour. - let hole_start_data = StartPathData::new(clockwise, link, left_top_link); + // Reverse both the starting edge and traversal to give holes the opposite winding. + let hole_start_data = StartPathData::new(!clockwise, link, left_top_link); self.find_contour( &hole_start_data, - clockwise, + !clockwise, VisitState::HoleVisited, contour_visited, points, diff --git a/iOverlay/tests/integer_grid_oracle_tests.rs b/iOverlay/tests/integer_grid_oracle_tests.rs new file mode 100644 index 00000000..200f9d1f --- /dev/null +++ b/iOverlay/tests/integer_grid_oracle_tests.rs @@ -0,0 +1,170 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{ContourDirection, Overlay}; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::solver::Solver; + +#[derive(Debug)] +struct Rect { + x0: i32, + y0: i32, + x1: i32, + y1: i32, + sign: i32, +} + +impl Rect { + fn contour(&self) -> Vec { + let mut result = vec![ + IntPoint::new(2 * self.x0, 2 * self.y0), + IntPoint::new(2 * self.x1, 2 * self.y0), + IntPoint::new(2 * self.x1, 2 * self.y1), + IntPoint::new(2 * self.x0, 2 * self.y1), + ]; + if self.sign < 0 { + result.reverse(); + } + result + } +} + +fn fill(rects: &[Rect], x: i32, y: i32, rule: FillRule) -> bool { + let count: i32 = rects + .iter() + .filter(|r| r.x0 <= x && x < r.x1 && r.y0 <= y && y < r.y1) + .map(|r| r.sign) + .sum(); + match rule { + FillRule::EvenOdd => count % 2 != 0, + FillRule::NonZero => count != 0, + FillRule::Positive => count > 0, + FillRule::Negative => count < 0, + } +} + +fn contains(shape: &[Vec], x: i32, y: i32) -> bool { + let mut inside = false; + for path in shape { + let mut a = path[path.len() - 1]; + for &b in path { + assert!( + a.x == b.x || a.y == b.y, + "rectangle overlays must remain orthogonal" + ); + if (a.y > y) != (b.y > y) && a.x > x { + inside = !inside; + } + a = b; + } + } + inside +} + +#[test] +fn rectangle_overlays_match_independent_cell_winding() { + let mut state = 0x7114_58bb_aa16_5841_u64; + let mut next = || { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); + (state >> 32) as i32 & 0x7fff_ffff + }; + for case in 0..1000 { + let mut rects = |n| { + (0..n) + .map(|_| { + let x0 = next() % 8; + let y0 = next() % 8; + Rect { + x0, + y0, + x1: x0 + 1 + next() % (8 - x0), + y1: y0 + 1 + next() % (8 - y0), + sign: if next() % 2 == 0 { 1 } else { -1 }, + } + }) + .collect::>() + }; + let a = rects(1 + case % 7); + let b = rects(1 + case % 5); + let mut overlay = Overlay::with_contours( + &a.iter().map(Rect::contour).collect::>(), + &b.iter().map(Rect::contour).collect::>(), + ); + overlay.solver = [Solver::LIST, Solver::TREE, Solver::FRAG][case % 3]; + overlay.options.ogc = case % 2 == 0; + let clockwise = case % 4 < 2; + overlay.options.output_direction = if clockwise { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + for fill_rule in [ + FillRule::EvenOdd, + FillRule::NonZero, + FillRule::Positive, + FillRule::Negative, + ] { + for rule in [ + OverlayRule::Union, + OverlayRule::Intersect, + OverlayRule::Difference, + OverlayRule::Xor, + ] { + let result = overlay.overlay(rule, fill_rule); + let mut cells = 0; + for x in 0..8 { + for y in 0..8 { + let sa = fill(&a, x, y, fill_rule); + let sb = fill(&b, x, y, fill_rule); + let expected = match rule { + OverlayRule::Union => sa || sb, + OverlayRule::Intersect => sa && sb, + OverlayRule::Difference => sa && !sb, + OverlayRule::Xor => sa != sb, + _ => unreachable!(), + }; + cells += i64::from(expected); + let covered = result + .iter() + .filter(|s| contains(s, 2 * x + 1, 2 * y + 1)) + .count(); + assert_eq!( + covered, + usize::from(expected), + "case={case}, cell=({x},{y}), fill={fill_rule:?}, rule={rule:?}, a={a:?}, b={b:?}" + ); + } + } + let mut double_area = 0_i64; + for shape in &result { + for (index, path) in shape.iter().enumerate() { + let area: i64 = path + .iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| i64::from(a.x) * i64::from(b.y) - i64::from(a.y) * i64::from(b.x)) + .sum(); + assert_eq!( + area > 0, + (index == 0) != clockwise, + "case={case}, ogc={}, clockwise={clockwise}, fill={fill_rule:?}, rule={rule:?}, index={index}, path={path:?}, a={a:?}, b={b:?}", + overlay.options.ogc + ); + double_area += area; + if overlay.options.ogc { + let unique: std::collections::BTreeSet<_> = path.iter().collect(); + assert_eq!( + unique.len(), + path.len(), + "OGC contour repeats a vertex: case={case}, path={path:?}" + ); + } + } + } + assert_eq!( + double_area.abs(), + cells * 8, + "case={case}, fill={fill_rule:?}, rule={rule:?}" + ); + } + } + } +} diff --git a/iOverlay/tests/integer_invariance_tests.rs b/iOverlay/tests/integer_invariance_tests.rs new file mode 100644 index 00000000..f27c6fe4 --- /dev/null +++ b/iOverlay/tests/integer_invariance_tests.rs @@ -0,0 +1,97 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{Overlay, ShapeType}; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::solver::Solver; + +type Shapes = Vec>>; + +fn canonical(mut shapes: Shapes) -> Shapes { + for shape in &mut shapes { + for path in shape.iter_mut() { + let start = (0..path.len()) + .min_by(|&a, &b| { + (0..path.len()) + .map(|i| path[(a + i) % path.len()]) + .cmp((0..path.len()).map(|i| path[(b + i) % path.len()])) + }) + .unwrap(); + path.rotate_left(start); + } + shape[1..].sort(); + } + shapes.sort(); + shapes +} + +#[test] +fn boolean_results_ignore_contour_start_direction_and_operand_order() { + let mut state = 0x7798_117b_13ac_7331_u64; + let mut next = || { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); + ((state >> 32) % 17) as i32 - 8 + }; + for case in 0..2000 { + let a: Vec<_> = (0..3 + case % 9).map(|_| IntPoint::new(next(), next())).collect(); + let b: Vec<_> = (0..3 + case % 7).map(|_| IntPoint::new(next(), next())).collect(); + let mut reversed_a = a.clone(); + let mut reversed_b = b.clone(); + reversed_a.reverse(); + reversed_b.reverse(); + reversed_a.rotate_left(case % a.len()); + reversed_b.rotate_left(case % b.len()); + for fill in [FillRule::EvenOdd, FillRule::NonZero] { + for rule in [OverlayRule::Intersect, OverlayRule::Union, OverlayRule::Xor] { + let expected = canonical(Overlay::with_contour(&a, &b).overlay(rule, fill)); + let actual = canonical(Overlay::with_contour(&reversed_b, &reversed_a).overlay(rule, fill)); + assert_eq!( + actual, expected, + "case={case}, fill={fill:?}, rule={rule:?}, a={a:?}, b={b:?}" + ); + } + } + } +} + +#[test] +fn adding_rectangles_after_extraction_matches_a_fresh_overlay() { + let mut state = 0x43ba_2846_7311_9133_u64; + let mut next = || { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); + ((state >> 32) % 33) as i32 - 16 + }; + for case in 0..1000 { + // Keep intersections exact: sequential snapping can legitimately differ + // from splitting all arbitrary edges in a single pass. + let mut rectangle = || { + let x = next(); + let y = next(); + let w = next().abs() + 1; + let h = next().abs() + 1; + vec![ + IntPoint::new(x, y), + IntPoint::new(x + w, y), + IntPoint::new(x + w, y + h), + IntPoint::new(x, y + h), + ] + }; + let a = rectangle(); + let b = rectangle(); + let c = rectangle(); + for solver in [Solver::LIST, Solver::TREE, Solver::FRAG] { + let mut reused = Overlay::with_contour(&a, &b); + reused.solver = solver; + reused.overlay(OverlayRule::Intersect, FillRule::EvenOdd); + reused.add_contour(&c, ShapeType::Subject); + let mut fresh = Overlay::with_contour(&a, &b); + fresh.solver = solver; + fresh.add_contour(&c, ShapeType::Subject); + assert_eq!( + canonical(reused.overlay(OverlayRule::Union, FillRule::EvenOdd)), + canonical(fresh.overlay(OverlayRule::Union, FillRule::EvenOdd)), + "case={case}, solver={:?}, a={a:?}, b={b:?}, c={c:?}", + solver.strategy, + ); + } + } +} diff --git a/iOverlay/tests/mesh_scale_tests.rs b/iOverlay/tests/mesh_scale_tests.rs new file mode 100644 index 00000000..97317a05 --- /dev/null +++ b/iOverlay/tests/mesh_scale_tests.rs @@ -0,0 +1,141 @@ +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::float::overlay::FloatOverlay; +use i_overlay::mesh::outline::offset::OutlineOffset; +use i_overlay::mesh::stroke::offset::StrokeOffset; +use i_overlay::mesh::style::{LineJoin, OutlineStyle, StrokeStyle}; + +fn normalized_area(shapes: &[Vec>], scale: f64) -> f64 { + shapes + .iter() + .flatten() + .map(|path| { + let mut sum = 0.0; + let mut a = path[path.len() - 1].map(|v| v / scale); + for p in path { + let b = p.map(|v| v / scale); + sum += a[0] * b[1] - a[1] * b[0]; + a = b; + } + sum + }) + .sum::() + .abs() + * 0.5 +} + +#[test] +fn float_overlay_preserves_rectangles_at_extreme_scales() { + for exponent in [600, -600] { + let scale = 2.0_f64.powi(exponent); + let rectangle = [ + [0.0, -scale], + [10.0 * scale, -scale], + [10.0 * scale, scale], + [0.0, scale], + ]; + let shapes = FloatOverlay::with_subj(&rectangle).overlay(OverlayRule::Subject, FillRule::NonZero); + let area = normalized_area(&shapes, scale); + assert!( + (area - 20.0).abs() < 0.001, + "exponent={exponent}, normalized area={area}" + ); + } +} + +#[test] +#[ignore = "Known extreme-scale mesh bug; deferred by request"] +fn stroke_preserves_relative_area_across_coordinate_scales() { + for exponent in [0, 400, -400, 600, -600] { + let scale = 2.0_f64.powi(exponent); + let path = [[0.0, 0.0], [10.0 * scale, 0.0]]; + let shapes = path.stroke(StrokeStyle::new(2.0 * scale), false); + let area = normalized_area(&shapes, scale); + assert!( + (area - 20.0).abs() < 0.001, + "exponent={exponent}, normalized area={area}, shapes={shapes:?}" + ); + } +} + +#[test] +#[ignore = "Known extreme-scale mesh bug; deferred by request"] +fn stroke_preserves_relative_area_at_small_coordinates() { + let scale = 2.0_f64.powi(-600); + let path = [[0.0, 0.0], [10.0 * scale, 0.0]]; + let shapes = path.stroke(StrokeStyle::new(2.0 * scale), false); + let area = normalized_area(&shapes, scale); + assert!( + (area - 20.0).abs() < 0.001, + "normalized area={area}, shapes={shapes:?}" + ); +} + +#[test] +#[ignore = "Known extreme-scale mesh bug; deferred by request"] +fn f32_stroke_preserves_relative_area_at_large_coordinates() { + check_f32_stroke(80); +} + +#[test] +#[ignore = "Known extreme-scale mesh bug; deferred by request"] +fn f32_stroke_preserves_relative_area_at_small_coordinates() { + check_f32_stroke(-80); +} + +fn check_f32_stroke(exponent: i32) { + let scale = 2.0_f32.powi(exponent); + let path = [[0.0, 0.0], [10.0 * scale, 0.0]]; + let shapes = path.stroke(StrokeStyle::new(2.0 * scale), false); + let shapes: Vec>> = shapes + .into_iter() + .map(|s| { + s.into_iter() + .map(|c| c.into_iter().map(|p| p.map(f64::from)).collect()) + .collect() + }) + .collect(); + let area = normalized_area(&shapes, f64::from(scale)); + assert!( + (area - 20.0).abs() < 0.001, + "exponent={exponent}, normalized area={area}, shapes={shapes:?}" + ); +} + +#[test] +#[ignore = "Known extreme-scale mesh bug; deferred by request"] +fn bevel_outline_preserves_relative_area_at_large_coordinates() { + let scale = 2.0_f64.powi(600); + let path = [ + [0.0, 0.0], + [10.0 * scale, 0.0], + [10.0 * scale, 10.0 * scale], + [0.0, 10.0 * scale], + ]; + let shapes = path.outline(&OutlineStyle::new(scale)); + let area = normalized_area(&shapes, scale); + assert!( + (area - 142.0).abs() < 0.001, + "normalized area={area}, shapes={shapes:?}" + ); +} + +#[test] +#[ignore = "Known extreme-scale mesh bug; deferred by request"] +fn outline_preserves_relative_area_across_coordinate_scales() { + for exponent in [0, 400, -400, 600, -600] { + let scale = 2.0_f64.powi(exponent); + let path = [ + [0.0, 0.0], + [10.0 * scale, 0.0], + [10.0 * scale, 10.0 * scale], + [0.0, 10.0 * scale], + ]; + let shapes = path.outline(&OutlineStyle::new(scale).line_join(LineJoin::Miter(0.2))); + let area = normalized_area(&shapes, scale); + assert!( + (area - 144.0).abs() < 0.001, + "exponent={exponent}, normalized area={area}, shapes={shapes:?}" + ); + } +} diff --git a/iOverlay/tests/ogc_winding_tests.rs b/iOverlay/tests/ogc_winding_tests.rs new file mode 100644 index 00000000..5f6dc29b --- /dev/null +++ b/iOverlay/tests/ogc_winding_tests.rs @@ -0,0 +1,79 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{ContourDirection, Overlay}; +use i_overlay::core::overlay_rule::OverlayRule; + +fn touching_hole() -> Vec { + [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + [0, 5], + [2, 7], + [4, 5], + [2, 3], + [0, 5], + ] + .map(|p| IntPoint::new(p[0], p[1])) + .to_vec() +} + +fn double_area(path: &[IntPoint]) -> i64 { + path.iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| i64::from(a.x) * i64::from(b.y) - i64::from(a.y) * i64::from(b.x)) + .sum() +} + +#[test] +fn ogc_touching_hole_has_opposite_winding() { + check_touching_hole(false); +} + +#[test] +fn clockwise_ogc_touching_hole_has_opposite_winding() { + check_touching_hole(true); +} + +fn check_touching_hole(clockwise: bool) { + let mut overlay = Overlay::with_contour(&touching_hole(), &[]); + overlay.options.ogc = true; + overlay.options.output_direction = if clockwise { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + let shapes = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(shapes.len(), 1); + assert_eq!(shapes[0].len(), 2); + assert_eq!(double_area(&shapes[0][0]) > 0, !clockwise); + assert_eq!( + double_area(&shapes[0][1]) > 0, + clockwise, + "clockwise={clockwise}, shapes={shapes:?}" + ); +} + +#[test] +fn ordinary_extraction_preserves_touching_hole_area() { + let shapes = + Overlay::with_contour(&touching_hole(), &[]).overlay(OverlayRule::Subject, FillRule::NonZero); + let area: i64 = shapes.iter().flatten().map(|p| double_area(p)).sum(); + assert_eq!(area, 184); +} + +#[test] +fn ogc_output_preserves_holes_when_reused_with_nonzero_fill() { + let path = touching_hole(); + let mut overlay = Overlay::with_contour(&path, &[]); + overlay.options.ogc = true; + let shapes = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); + let contours: Vec<_> = shapes.into_iter().flatten().collect(); + let result = Overlay::with_contours(&contours, &[]).overlay(OverlayRule::Subject, FillRule::NonZero); + let area: i64 = result.iter().flatten().map(|p| double_area(p)).sum(); + assert_eq!( + area, 184, + "the area must remain 100 - 8 after reusing OGC output; result={result:?}" + ); +} From 5a4add49cd8bcb970416d8a45d1fbe45d0866f08 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Thu, 10 Sep 2026 13:35:55 +0300 Subject: [PATCH 11/44] fix edge direction in reverse order --- iOverlay/src/bind/segment.rs | 27 +++++-- iOverlay/tests/hierarchy_touch_tests.rs | 50 ++++++++++++ iOverlay/tests/output_consistency_tests.rs | 87 +++++++++++++++++++++ iOverlay/tests/vector_hole_binding_tests.rs | 43 ++++++++++ 4 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 iOverlay/tests/hierarchy_touch_tests.rs create mode 100644 iOverlay/tests/output_consistency_tests.rs create mode 100644 iOverlay/tests/vector_hole_binding_tests.rs diff --git a/iOverlay/src/bind/segment.rs b/iOverlay/src/bind/segment.rs index afbcc143..140b8896 100644 --- a/iOverlay/src/bind/segment.rs +++ b/iOverlay/src/bind/segment.rs @@ -1,5 +1,5 @@ use crate::geom::v_segment::VSegment; -use crate::vector::edge::{DataVectorEdge, DataVectorPath}; +use crate::vector::edge::DataVectorPath; use alloc::vec::Vec; use i_float::int::number::int::IntNumber; use i_float::int::point::IntPoint; @@ -125,24 +125,37 @@ impl IdSegments for DataVectorPath { x_max: I, clockwise: bool, ) { - fn inner<'a, D: 'a, I: IntNumber + 'a, It: Iterator>>( + fn inner, IntPoint)>>( iter: It, buffer: &mut Vec>, id_data: ContourIndex, x_min: I, x_max: I, ) { - for vec in iter { - if vec.a.x < vec.b.x && x_min < vec.b.x && vec.a.x <= x_max { - buffer.push(IdSegment::::new(id_data, vec.a, vec.b)); + for (a, b) in iter { + if a.x < b.x && x_min < b.x && a.x <= x_max { + buffer.push(IdSegment::::new(id_data, a, b)); } } } if clockwise { - inner(self.iter(), buffer, id_data, x_min, x_max); + // Reversing edge order does not reverse their endpoints. + inner( + self.iter().map(|edge| (edge.b, edge.a)), + buffer, + id_data, + x_min, + x_max, + ); } else { - inner(self.iter().rev(), buffer, id_data, x_min, x_max); + inner( + self.iter().map(|edge| (edge.a, edge.b)), + buffer, + id_data, + x_min, + x_max, + ); } } } diff --git a/iOverlay/tests/hierarchy_touch_tests.rs b/iOverlay/tests/hierarchy_touch_tests.rs new file mode 100644 index 00000000..5d53a247 --- /dev/null +++ b/iOverlay/tests/hierarchy_touch_tests.rs @@ -0,0 +1,50 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{ContourDirection, Overlay}; +use i_overlay::core::overlay_rule::OverlayRule; + +fn contour(points: &[[i32; 2]]) -> Vec { + points.iter().map(|p| IntPoint::new(p[0], p[1])).collect() +} + +#[test] +fn island_touching_hole_boundary_keeps_its_parent() { + for clockwise in [false, true] { + for island in [ + [[2, 10], [6, 8], [8, 10], [6, 12]], + [[10, 2], [12, 6], [10, 8], [8, 6]], + [[18, 10], [14, 12], [12, 10], [14, 8]], + [[10, 18], [8, 14], [10, 12], [12, 14]], + ] { + let subject = vec![ + contour(&[[0, 0], [20, 0], [20, 20], [0, 20]]), + contour(&[[2, 2], [2, 18], [18, 18], [18, 2]]), + contour(&island), + ]; + let mut overlay = Overlay::with_contours(&subject, &[]); + overlay.options.ogc = true; + overlay.options.output_direction = if clockwise { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + let hierarchy = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::NonZero); + assert_eq!( + hierarchy.shapes.shape_ranges.len(), + 2, + "island={island:?}, clockwise={clockwise}, hierarchy={hierarchy:?}" + ); + assert_eq!( + hierarchy.links.len(), + 1, + "island={island:?}, clockwise={clockwise}, hierarchy={hierarchy:?}" + ); + let link = hierarchy.links[0]; + let parent = &hierarchy.shapes.shape_ranges[link.parent_shape_index]; + let child = &hierarchy.shapes.shape_ranges[link.child_shape_index]; + assert_eq!(parent.len(), 2); + assert_eq!(child.len(), 1); + assert_eq!(link.parent_contour_index, parent.start + 1); + } + } +} diff --git a/iOverlay/tests/output_consistency_tests.rs b/iOverlay/tests/output_consistency_tests.rs new file mode 100644 index 00000000..fa27cdd7 --- /dev/null +++ b/iOverlay/tests/output_consistency_tests.rs @@ -0,0 +1,87 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{ContourDirection, Overlay}; +use i_overlay::core::overlay_rule::OverlayRule; +use i_shape::flat::buffer::FlatContoursBuffer; + +fn canonical(mut paths: Vec>) -> Vec> { + for path in &mut paths { + let start = (0..path.len()) + .min_by(|&a, &b| { + (0..path.len()) + .map(|j| path[(a + j) % path.len()]) + .cmp((0..path.len()).map(|j| path[(b + j) % path.len()])) + }) + .unwrap(); + path.rotate_left(start); + } + paths.sort(); + paths +} + +#[test] +fn flat_and_vector_outputs_match_shapes_with_area_filtering() { + let mut seed = 0x61ab_117c_0018_23ad_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 41) as i32 - 20 + }; + for case in 0..1000 { + let a: Vec<_> = (0..3 + case % 8).map(|_| IntPoint::new(next(), next())).collect(); + let b: Vec<_> = (0..3 + case % 9).map(|_| IntPoint::new(next(), next())).collect(); + for threshold in [0, 1, 10, 50] { + let mut regular = Overlay::with_contour(&a, &b); + regular.options.min_output_area = threshold; + regular.options.preserve_output_collinear = case % 2 == 0; + regular.options.output_direction = if case % 3 == 0 { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + for rule in [ + OverlayRule::Union, + OverlayRule::Intersect, + OverlayRule::Difference, + OverlayRule::Xor, + ] { + let fill = [ + FillRule::EvenOdd, + FillRule::NonZero, + FillRule::Positive, + FillRule::Negative, + ][case % 4]; + let shapes = regular.overlay(rule, fill); + let expected = canonical(shapes.into_iter().flatten().collect()); + let mut flat = FlatContoursBuffer::default(); + regular.overlay_into(rule, fill, &mut flat); + let actual = canonical( + flat.ranges + .iter() + .map(|r| flat.points[r.clone()].to_vec()) + .collect(), + ); + assert_eq!( + actual, expected, + "flat: case={case}, threshold={threshold}, rule={rule:?}, a={a:?}, b={b:?}" + ); + let shapes = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + regular.build_shape_vectors(fill, rule) + })) + .unwrap_or_else(|_| { + panic!("vector extraction panicked: case={case}, threshold={threshold}, rule={rule:?}, fill={fill:?}, a={a:?}, b={b:?}") + }); + let actual = canonical( + shapes + .into_iter() + .flatten() + .map(|p| p.into_iter().map(|e| e.a).collect()) + .collect(), + ); + assert_eq!( + actual, expected, + "vectors: case={case}, threshold={threshold}, rule={rule:?}, a={a:?}, b={b:?}" + ); + } + } + } +} diff --git a/iOverlay/tests/vector_hole_binding_tests.rs b/iOverlay/tests/vector_hole_binding_tests.rs new file mode 100644 index 00000000..cedbbb43 --- /dev/null +++ b/iOverlay/tests/vector_hole_binding_tests.rs @@ -0,0 +1,43 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::edge_overlay::{EdgeOverlay, InputEdge}; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{ContourDirection, ShapeType}; +use i_overlay::core::overlay_rule::OverlayRule; + +fn check_hole_binding(direction: ContourDirection) { + let contours = [ + [[0, 0], [10, 0], [10, 10], [0, 10]], + [[2, 2], [2, 8], [8, 8], [8, 2]], + [[20, 0], [30, 0], [30, 10], [20, 10]], + ]; + let mut overlay = EdgeOverlay::::new(12); + overlay.options.output_direction = direction; + for contour in contours { + for (a, b) in contour.iter().zip(contour.iter().cycle().skip(1)) { + overlay.add_edge( + InputEdge { + a: IntPoint::new(a[0], a[1]), + b: IntPoint::new(b[0], b[1]), + data: (), + }, + ShapeType::Subject, + ); + } + } + let shapes = overlay.build_vector_shapes(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(shapes.len(), 2); + let left = shapes.iter().find(|s| s[0].iter().any(|e| e.a.x == 0)).unwrap(); + let right = shapes.iter().find(|s| s[0].iter().any(|e| e.a.x == 20)).unwrap(); + assert_eq!(left.len(), 2, "the left square must own the hole"); + assert_eq!(right.len(), 1, "the right square has no hole"); +} + +#[test] +fn counterclockwise_vector_output_binds_hole_to_its_shape() { + check_hole_binding(ContourDirection::CounterClockwise); +} + +#[test] +fn clockwise_vector_output_binds_hole_to_its_shape() { + check_hole_binding(ContourDirection::Clockwise); +} From b3a5fbbfa8dc321ab6ad7c9b833e66c543082fd0 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Thu, 10 Sep 2026 14:08:53 +0300 Subject: [PATCH 12/44] offset inner overlay area filter fix --- iOverlay/src/mesh/outline/offset.rs | 2 + iOverlay/tests/clip_coverage_tests.rs | 91 +++++++++++++ iOverlay/tests/hierarchy_touch_tests.rs | 74 ++++++++++ iOverlay/tests/outline_boundary_tests.rs | 31 +++++ iOverlay/tests/outline_pipeline_tests.rs | 126 ++++++++++++++++++ .../tests/point_location_threshold_tests.rs | 53 ++++++++ iOverlay/tests/stroke_coverage_tests.rs | 88 ++++++++++++ 7 files changed, 465 insertions(+) create mode 100644 iOverlay/tests/clip_coverage_tests.rs create mode 100644 iOverlay/tests/outline_pipeline_tests.rs create mode 100644 iOverlay/tests/point_location_threshold_tests.rs diff --git a/iOverlay/src/mesh/outline/offset.rs b/iOverlay/src/mesh/outline/offset.rs index 96c8bf95..2b835026 100644 --- a/iOverlay/src/mesh/outline/offset.rs +++ b/iOverlay/src/mesh/outline/offset.rs @@ -478,6 +478,8 @@ where let mut offset_overlay = Overlay::new(16); offset_overlay.options = overlay.options; + // Small offset contours can merge into a larger result; filter area only after union. + offset_overlay.options.min_output_area = I::WideUInt::ZERO; let mut segments = Vec::new(); let mut bool_buffer = BooleanExtractionBuffer::default(); diff --git a/iOverlay/tests/clip_coverage_tests.rs b/iOverlay/tests/clip_coverage_tests.rs new file mode 100644 index 00000000..9930decb --- /dev/null +++ b/iOverlay/tests/clip_coverage_tests.rs @@ -0,0 +1,91 @@ +use std::collections::BTreeSet; + +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::string::clip::ClipRule; +use i_overlay::string::overlay::StringOverlay; + +type Edge = (i32, i32, i32, i32); + +fn unit_edges(paths: &[Vec]) -> BTreeSet { + let mut edges = BTreeSet::new(); + for path in paths { + for pair in path.windows(2) { + let [a, b] = [pair[0], pair[1]]; + assert!(a != b && (a.x == b.x || a.y == b.y)); + let (dx, dy) = ((b.x - a.x).signum(), (b.y - a.y).signum()); + let (mut x, mut y) = (a.x, a.y); + while (x, y) != (b.x, b.y) { + assert!(edges.insert((x, y, x + dx, y + dy)), "duplicate directed edge"); + x += dx; + y += dy; + } + } + } + edges +} + +#[test] +fn grid_clipping_preserves_directed_coverage() { + let subject = [ + IntPoint::new(0, 0), + IntPoint::new(10, 0), + IntPoint::new(10, 10), + IntPoint::new(0, 10), + ]; + let mut seed = 0x7189_d91a_c29b_13f5_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 15) as i32 - 2 + }; + for case in 0..1000 { + let mut lines = Vec::new(); + let mut input = BTreeSet::new(); + for index in 0..40 { + let (x, y, t) = (next(), next(), next()); + let a = IntPoint::new(x, y); + let b = if index % 2 == 0 { + IntPoint::new(t, y) + } else { + IntPoint::new(x, t) + }; + lines.push([a, b]); + if a != b { + input.extend(unit_edges(&[vec![a, b]])); + } + } + for boundary_included in [false, true] { + for invert in [false, true] { + // Doubled midpoints classify unit edges without floating point + // or dependence on the clipping implementation. + let expected: BTreeSet<_> = input + .iter() + .copied() + .filter(|&(ax, ay, bx, by)| { + let (x, y) = (ax + bx, ay + by); + let inside = if boundary_included { + (0..=20).contains(&x) && (0..=20).contains(&y) + } else { + (1..20).contains(&x) && (1..20).contains(&y) + }; + inside != invert + }) + .collect(); + let mut overlay = StringOverlay::with_shape_contour(&subject); + overlay.add_string_lines(&lines); + let result = overlay.clip_string_lines( + FillRule::NonZero, + ClipRule { + invert, + boundary_included, + }, + ); + assert_eq!( + unit_edges(&result), + expected, + "case={case}, invert={invert}, boundary={boundary_included}, lines={lines:?}, result={result:?}" + ); + } + } + } +} diff --git a/iOverlay/tests/hierarchy_touch_tests.rs b/iOverlay/tests/hierarchy_touch_tests.rs index 5d53a247..9c44499a 100644 --- a/iOverlay/tests/hierarchy_touch_tests.rs +++ b/iOverlay/tests/hierarchy_touch_tests.rs @@ -48,3 +48,77 @@ fn island_touching_hole_boundary_keeps_its_parent() { } } } + +#[test] +fn holes_sharing_one_vertex_keep_their_own_islands() { + let outer = contour(&[[0, 0], [40, 0], [40, 40], [0, 40]]); + let mut subject = vec![outer]; + for rotation in 0..4 { + let rotate = |mut p: [i32; 2]| { + for _ in 0..rotation { + p = [40 - p[1], p[0]]; + } + p + }; + // Clockwise holes touch at (20,20), with one CCW island per hole. + subject.push(contour(&[[20, 20], [14, 28], [20, 36], [26, 28]].map(rotate))); + subject.push(contour(&[[19, 27], [21, 27], [21, 29], [19, 29]].map(rotate))); + } + for clockwise in [false, true] { + for reversed_input_order in [false, true] { + if reversed_input_order { + subject.reverse(); + } + let mut overlay = Overlay::with_contours(&subject, &[]); + overlay.options.ogc = true; + overlay.options.output_direction = if clockwise { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + let hierarchy = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::NonZero); + let flat = &hierarchy.shapes; + assert_eq!(flat.shape_ranges.len(), 5, "{hierarchy:?}"); + assert_eq!(hierarchy.links.len(), 4, "{hierarchy:?}"); + let mut total_area_two = 0_i64; + for shape in &flat.shape_ranges { + for (local, range) in flat.contour_ranges[shape.clone()].iter().enumerate() { + let path = &flat.points[range.clone()]; + let area_two: i64 = path + .iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| i64::from(a.x) * i64::from(b.y) - i64::from(a.y) * i64::from(b.x)) + .sum(); + assert_eq!(area_two < 0, clockwise != (local != 0)); + total_area_two += area_two; + let unique: std::collections::BTreeSet<_> = path.iter().map(|p| (p.x, p.y)).collect(); + assert_eq!(unique.len(), path.len(), "OGC contour repeats a vertex"); + } + } + assert_eq!(total_area_two.abs(), 2 * (1600 - 4 * 96 + 4 * 4)); + let mut owners = std::collections::BTreeSet::new(); + for link in &hierarchy.links { + assert!(owners.insert(link.parent_contour_index)); + let parent = &flat.shape_ranges[link.parent_shape_index]; + assert_eq!(parent.len(), 5); + assert!(link.parent_contour_index > parent.start && link.parent_contour_index < parent.end); + let child = &flat.shape_ranges[link.child_shape_index]; + assert_eq!(child.len(), 1); + let child_points = &flat.points[flat.contour_ranges[child.start].clone()]; + let hole_points = &flat.points[flat.contour_ranges[link.parent_contour_index].clone()]; + // Every island vertex must lie strictly inside its convex hole. + for p in child_points { + let crosses: Vec<_> = hole_points + .iter() + .zip(hole_points.iter().cycle().skip(1)) + .map(|(a, b)| (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)) + .collect(); + assert!( + crosses.iter().all(|&c| c > 0) || crosses.iter().all(|&c| c < 0), + "wrong hole owner: {hierarchy:?}" + ); + } + } + } + } +} diff --git a/iOverlay/tests/outline_boundary_tests.rs b/iOverlay/tests/outline_boundary_tests.rs index 32a03a48..9c021f56 100644 --- a/iOverlay/tests/outline_boundary_tests.rs +++ b/iOverlay/tests/outline_boundary_tests.rs @@ -95,3 +95,34 @@ fn hypothesis_collapsed_hole_does_not_erase_other_holes() { "a collapsed hole must not change any other contour" ); } + +#[test] +fn rectangular_frame_offsets_match_analytic_area() { + let source = vec![square(0.0, 30.0, false), square(10.0, 20.0, true)]; + for outer_offset in -16..=6 { + for inner_offset in -6..=6 { + let style = OutlineStyle::new(0.0) + .outer_offset(outer_offset as f64) + .inner_offset(inner_offset as f64) + .line_join(LineJoin::Miter(0.1)); + let result = source.outline_fixed_scale(&style, 100.0).unwrap(); + let outer_side = (30.0 + 2.0 * outer_offset as f64).max(0.0); + let hole_side = (10.0 - 2.0 * inner_offset as f64).max(0.0); + let expected = (outer_side * outer_side - hole_side * hole_side).max(0.0); + let actual: f64 = result + .iter() + .flatten() + .map(|path| { + path.iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| (a[0] * b[1] - a[1] * b[0]) * 0.5) + .sum::() + }) + .sum(); + assert!( + (actual - expected).abs() < 0.001, + "outer={outer_offset}, inner={inner_offset}, expected={expected}, actual={actual}, result={result:?}" + ); + } + } +} diff --git a/iOverlay/tests/outline_pipeline_tests.rs b/iOverlay/tests/outline_pipeline_tests.rs new file mode 100644 index 00000000..2a3e6fa3 --- /dev/null +++ b/iOverlay/tests/outline_pipeline_tests.rs @@ -0,0 +1,126 @@ +use i_overlay::float::overlay::OverlayOptions; +use i_overlay::mesh::outline::offset::OutlineOffset; +use i_overlay::mesh::stroke::offset::StrokeOffset; +use i_overlay::mesh::style::{LineJoin, OutlineStyle, StrokeStyle}; +use i_shape::flat::float::FloatFlatContoursBuffer; + +fn rectangle(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<[f64; 2]> { + vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]] +} + +#[test] +fn reused_mesh_output_matches_fresh_buffer_after_empty_results() { + let paths = [ + rectangle(0.0, 0.0, 10.0, 10.0), + vec![], + vec![[1.0, 1.0]], + vec![[1.0, 1.0]; 4], + vec![[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]], + rectangle(20.0, 20.0, 21.0, 21.0), + rectangle(30.0, 30.0, 40.0, 40.0), + ]; + let mut reused = FloatFlatContoursBuffer::default(); + for fixed in [false, true] { + for offset in [-1.0, 0.0, 1.0] { + let outline = OutlineStyle::new(offset); + for path in &paths { + let mut fresh = FloatFlatContoursBuffer::default(); + for output in [&mut reused, &mut fresh] { + if fixed { + path.outline_fixed_scale_into(&outline, 100.0, output).unwrap(); + } else { + path.outline_into(&outline, output); + } + } + assert_eq!( + reused.points, fresh.points, + "outline offset={offset}, path={path:?}" + ); + assert_eq!(reused.ranges, fresh.ranges); + for closed in [false, true] { + let mut fresh = FloatFlatContoursBuffer::default(); + for output in [&mut reused, &mut fresh] { + if fixed { + path.stroke_fixed_scale_into(StrokeStyle::new(2.0), closed, 100.0, output) + .unwrap(); + } else { + path.stroke_into(StrokeStyle::new(2.0), closed, output); + } + } + assert_eq!( + reused.points, fresh.points, + "stroke closed={closed}, path={path:?}" + ); + assert_eq!(reused.ranges, fresh.ranges); + } + } + } + } +} + +#[test] +fn collapsed_offsets_do_not_change_surviving_contours_when_reordered() { + let mut hole = rectangle(10.0, 10.0, 20.0, 20.0); + hole.reverse(); + let mut small_hole = rectangle(3.0, 3.0, 5.0, 5.0); + small_hole.reverse(); + let paths = [rectangle(0.0, 0.0, 30.0, 30.0), small_hole, hole]; + for offset in [1.0, 2.0, 5.0, 6.0] { + let style = OutlineStyle::new(offset); + let expected = paths.outline_fixed_scale(&style, 100.0).unwrap(); + for order in [[0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] { + let source: Vec<_> = order.iter().map(|&i| paths[i].clone()).collect(); + let actual = source.outline_fixed_scale(&style, 100.0).unwrap(); + assert_eq!(actual, expected, "offset={offset}, order={order:?}"); + } + } +} + +#[test] +fn outline_area_filter_keeps_small_offsets_that_merge_into_a_large_shape() { + check_area_filter_after_union(true); +} + +#[test] +fn automatic_scale_outline_area_filter_keeps_merged_shape() { + check_area_filter_after_union(false); +} + +fn check_area_filter_after_union(fixed: bool) { + // Disjoint input squares become overlapping 4x4 squares. Their union is + // a 7x4 rectangle: the final area 28 exceeds the threshold 20. + let source = [rectangle(0.0, 0.0, 2.0, 2.0), rectangle(3.0, 0.0, 5.0, 2.0)]; + let style = OutlineStyle::new(1.0).line_join(LineJoin::Miter(0.1)); + let extract = |options| { + if fixed { + source.outline_custom_fixed_scale(&style, options, 100.0).unwrap() + } else { + source.outline_custom(&style, options) + } + }; + let expected = extract(OverlayOptions::default()); + assert_eq!(expected.len(), 1); + let area: f64 = expected + .iter() + .flatten() + .map(|path| { + path.iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| 0.5 * (a[0] * b[1] - a[1] * b[0])) + .sum::() + }) + .sum(); + assert!((area - 28.0).abs() < 1e-6, "unfiltered area={area}"); + let mut options = OverlayOptions::default(); + options.min_output_area = 29.0; + assert!( + extract(options).is_empty(), + "the final area filter must still remove the union below threshold" + ); + options.min_output_area = 20.0; + let actual = extract(options); + assert_eq!( + actual, expected, + "minimum output area must be applied to the merged shape of area 28" + ); +} diff --git a/iOverlay/tests/point_location_threshold_tests.rs b/iOverlay/tests/point_location_threshold_tests.rs new file mode 100644 index 00000000..269bb188 --- /dev/null +++ b/iOverlay/tests/point_location_threshold_tests.rs @@ -0,0 +1,53 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::point_location::IntPointContainment; + +#[test] +fn point_location_matches_analytic_strip_on_both_sides_of_tree_threshold() { + for intervals in [3998_i32, 3999, 4000] { + // 7998, 8000, 8002 noncollinear vertices; input cleanup preserves all. + let mut contour: Vec<_> = (0..=intervals) + .map(|i| IntPoint::new(4 * i, 100 + 4 * (i % 2))) + .collect(); + contour.extend( + (0..=intervals) + .rev() + .map(|i| IntPoint::new(4 * i, -100 - 4 * (i % 2))), + ); + assert_eq!(contour.len(), 2 * (intervals as usize + 1)); + let mut queries = Vec::new(); + let mut expected = Vec::new(); + for x in -1..=4 * intervals + 1 { + if x == 0 || x == 4 * intervals { + continue; + } + let height = 100 + if (x / 4) % 2 == 0 { x % 4 } else { 4 - x % 4 }; + for y in [-105_i32, -103, -101, -99, 0, 99, 101, 103, 105] { + if y == height || y == -height { + continue; + } + queries.push(IntPoint::new(x, y)); + expected.push(x > 0 && x < 4 * intervals && y.abs() < height); + } + } + // Transposition produces thousands of simultaneously active edges. + for transpose in [false, true] { + if transpose { + for p in contour.iter_mut().chain(queries.iter_mut()) { + std::mem::swap(&mut p.x, &mut p.y); + } + } + for reverse in [false, true] { + if reverse { + contour.reverse(); + } + let actual = contour.contains_points(&queries); + if let Some(index) = actual.iter().zip(&expected).position(|(a, b)| a != b) { + panic!( + "intervals={intervals}, transpose={transpose}, reverse={reverse}, point={:?}, actual={}, expected={}", + queries[index], actual[index], expected[index] + ); + } + } + } + } +} diff --git a/iOverlay/tests/stroke_coverage_tests.rs b/iOverlay/tests/stroke_coverage_tests.rs index c7d444d8..f3fc05b1 100644 --- a/iOverlay/tests/stroke_coverage_tests.rs +++ b/iOverlay/tests/stroke_coverage_tests.rs @@ -124,3 +124,91 @@ fn variable_strokes_cover_vertex_disks() { assert_vertex_disks_covered(&shapes, &path, case); } } + +#[test] +fn variable_strokes_cover_interpolated_disks() { + let mut seed = 0xa153_61b9_911d_7283_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 41) as f64 - 20.0 + }; + for case in 0..3000 { + let path: Vec<_> = (0..2 + case % 8) + .map(|_| StrokeVertex::new([next(), next()], next() + 20.0)) + .collect(); + let shapes = path + .variable_stroke_fixed_scale(VariableStrokeStyle::new().round_angle(0.05), 10000.0) + .unwrap(); + let mut samples = Vec::new(); + for pair in path.windows(2) { + for step in 1..4 { + let t = step as f64 / 4.0; + let width = pair[0].width * (1.0 - t) + pair[1].width * t; + if width == 0.0 { + continue; + } + samples.push(StrokeVertex::new( + [ + pair[0].point[0] * (1.0 - t) + pair[1].point[0] * t, + pair[0].point[1] * (1.0 - t) + pair[1].point[1] * t, + ], + width, + )); + } + } + assert_vertex_disks_covered(&shapes, &samples, case); + } +} + +// Minimize squared distance minus squared interpolated radius along a +// centerline segment. This oracle does not construct joins or tangent edges. +fn within_variable_stroke(path: &[StrokeVertex<[f64; 2]>], p: [f64; 2]) -> bool { + path.windows(2).any(|pair| { + let a = pair[0]; + let b = pair[1]; + let dx = b.point[0] - a.point[0]; + let dy = b.point[1] - a.point[1]; + let x = p[0] - a.point[0]; + let y = p[1] - a.point[1]; + // Allow a margin for integer snapping and arc approximation. + let r = 0.5 * a.width + 0.03; + let dr = 0.5 * (b.width - a.width); + let qa = dx * dx + dy * dy - dr * dr; + let qb = -2.0 * (x * dx + y * dy + r * dr); + let qc = x * x + y * y - r * r; + let mut minimum = qc.min(qa + qb + qc); + if qa > 0.0 { + let t = (-qb / (2.0 * qa)).clamp(0.0, 1.0); + minimum = minimum.min((qa * t + qb) * t + qc); + } + minimum <= 0.0 + }) +} + +#[test] +fn variable_strokes_do_not_fill_outside_interpolated_disks() { + let mut seed = 0xa153_61b9_911d_7283_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 41) as f64 - 20.0 + }; + for case in 0..500 { + let path: Vec<_> = (0..2 + case % 8) + .map(|_| StrokeVertex::new([next(), next()], next() + 20.0)) + .collect(); + let shapes = path + .variable_stroke_fixed_scale(VariableStrokeStyle::new().round_angle(0.05), 10000.0) + .unwrap(); + for x in -40..=40 { + for y in -40..=40 { + let p = [x as f64 + 0.37, y as f64 + 0.29]; + if !within_variable_stroke(&path, p) { + assert!( + !contains(&shapes, p), + "excess area: case={case}, p={p:?}, path={path:?}, shapes={shapes:?}" + ); + } + } + } + } +} From ec107cd6ba7d24e0482f40a53f019cfdced27711 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Thu, 10 Sep 2026 14:20:22 +0300 Subject: [PATCH 13/44] fix degenerate case filter --- iOverlay/src/mesh/outline/offset.rs | 2 +- iOverlay/tests/outline_grid_area_tests.rs | 86 +++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 iOverlay/tests/outline_grid_area_tests.rs diff --git a/iOverlay/src/mesh/outline/offset.rs b/iOverlay/src/mesh/outline/offset.rs index 2b835026..c46e72dd 100644 --- a/iOverlay/src/mesh/outline/offset.rs +++ b/iOverlay/src/mesh/outline/offset.rs @@ -487,7 +487,7 @@ where for path in source.iter_paths() { let area = path.unsafe_int_area(&self.adapter); - if area.unsigned_abs() <= ::from_u64(1) { + if area == I::Wide::ZERO { // ignore degenerate paths continue; } diff --git a/iOverlay/tests/outline_grid_area_tests.rs b/iOverlay/tests/outline_grid_area_tests.rs new file mode 100644 index 00000000..3f1e0c8f --- /dev/null +++ b/iOverlay/tests/outline_grid_area_tests.rs @@ -0,0 +1,86 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::Overlay; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::mesh::outline::offset::OutlineOffset; +use i_overlay::mesh::style::OutlineStyle; + +fn area(shapes: &[Vec>]) -> f64 { + shapes + .iter() + .flatten() + .map(|path| { + path.iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| 0.5 * (a[0] * b[1] - a[1] * b[0])) + .sum::() + }) + .sum() +} + +#[test] +fn zero_outline_preserves_half_grid_cell_hole() { + // Symmetric bounds put the adapter origin at (0,0), so every vertex is + // exactly on the fixed grid. The clockwise triangle has double area -1. + let shape = vec![ + vec![[-10.0, -10.0], [10.0, -10.0], [10.0, 10.0], [-10.0, 10.0]], + vec![[0.0, 0.0], [0.0, 1.0], [1.0, 0.0]], + ]; + let result = shape.outline_fixed_scale(&OutlineStyle::new(0.0), 1.0).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!( + result[0].len(), + 2, + "a nonzero-area hole must survive zero offset: {result:?}" + ); + assert_eq!(area(&result), 399.5); +} + +#[test] +fn zero_outline_preserves_half_grid_cell_components() { + let shape = vec![ + vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], + vec![[0.0, 0.0], [-1.0, 0.0], [0.0, -1.0]], + ]; + let result = shape.outline_fixed_scale(&OutlineStyle::new(0.0), 1.0).unwrap(); + assert_eq!( + area(&result), + 1.0, + "two nondegenerate triangles must survive: {result:?}" + ); +} + +#[test] +fn outward_outline_expands_half_grid_cell_components() { + let shape = vec![ + vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], + vec![[0.0, 0.0], [-1.0, 0.0], [0.0, -1.0]], + ]; + let result = shape.outline_fixed_scale(&OutlineStyle::new(2.0), 1.0).unwrap(); + assert!( + area(&result) > 1.0, + "outward offset must expand the triangles, not discard them: {result:?}" + ); +} + +#[test] +fn integer_overlay_preserves_half_grid_cell_contours() { + let triangle = vec![IntPoint::new(0, 0), IntPoint::new(1, 0), IntPoint::new(0, 1)]; + let result = Overlay::with_contour(&triangle, &[]).overlay(OverlayRule::Subject, FillRule::Positive); + assert_eq!(result.len(), 1); + assert_eq!(result[0][0].len(), 3); + let mut hole = triangle; + hole.reverse(); + let shape = vec![ + vec![ + IntPoint::new(-10, -10), + IntPoint::new(10, -10), + IntPoint::new(10, 10), + IntPoint::new(-10, 10), + ], + hole, + ]; + let result = Overlay::with_contours(&shape, &[]).overlay(OverlayRule::Subject, FillRule::Positive); + assert_eq!(result.len(), 1); + assert_eq!(result[0].len(), 2); +} From ebbcbd603baa2228171bbd8edb4395323188a91a Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Fri, 11 Sep 2026 21:33:58 +0300 Subject: [PATCH 14/44] fix hole touching test --- iOverlay/src/core/extract_ogc.rs | 6 +- iOverlay/tests/ogc_hole_orientation_tests.rs | 75 ++++++++++ iOverlay/tests/ogc_traversal_tests.rs | 92 ++++++++++++ iOverlay/tests/outline_identity_tests.rs | 140 +++++++++++++++++++ 4 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 iOverlay/tests/ogc_hole_orientation_tests.rs create mode 100644 iOverlay/tests/ogc_traversal_tests.rs create mode 100644 iOverlay/tests/outline_identity_tests.rs diff --git a/iOverlay/src/core/extract_ogc.rs b/iOverlay/src/core/extract_ogc.rs index a4df95da..ddbd9756 100644 --- a/iOverlay/src/core/extract_ogc.rs +++ b/iOverlay/src/core/extract_ogc.rs @@ -224,6 +224,7 @@ where // First, mark all edges that belong to the contour. + let mut start_link_id = start_data.link_id; let mut end_link_id = start_data.link_id; global_visited.visit_edge(link_id, VisitState::HullVisited); @@ -263,6 +264,7 @@ where link.a.id }; end_link_id = end_link_id.max(link_id); + start_link_id = start_link_id.min(link_id); contour_visited.visit_edge(link_id, VisitState::Unvisited); global_visited.visit_edge(link_id, VisitState::HullVisited); original_contour_len += 1; @@ -298,7 +300,8 @@ where if contour_len < original_contour_len { // contour has self touches - let mut link_index = start_data.link_id; + let mut link_index = start_link_id; + while link_index <= end_link_id { if contour_visited.is_visited(link_index) { link_index += 1; @@ -318,7 +321,6 @@ where // Self-touch splits can only produce holes inside this contour. - // Reverse both the starting edge and traversal to give holes the opposite winding. let hole_start_data = StartPathData::new(!clockwise, link, left_top_link); self.find_contour( &hole_start_data, diff --git a/iOverlay/tests/ogc_hole_orientation_tests.rs b/iOverlay/tests/ogc_hole_orientation_tests.rs new file mode 100644 index 00000000..d2402fa4 --- /dev/null +++ b/iOverlay/tests/ogc_hole_orientation_tests.rs @@ -0,0 +1,75 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{ContourDirection, Overlay}; +use i_overlay::core::overlay_rule::OverlayRule; + +fn touching_hole() -> Vec { + // Outer area 6, hole area 1. The hole touches the leftmost outer vertex. + [[0, 1], [-1, 0], [0, 0], [0, -2], [-1, 0], [0, -3], [2, -1]] + .map(|p| IntPoint::new(p[0], p[1])) + .to_vec() +} + +fn double_area(path: &[IntPoint]) -> i64 { + path.iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| i64::from(a.x) * i64::from(b.y) - i64::from(a.y) * i64::from(b.x)) + .sum() +} + +fn check_hole_direction(clockwise: bool) { + for shift in 0..7 { + let mut path = touching_hole(); + path.rotate_left(shift); + let mut overlay = Overlay::with_contour(&path, &[]); + overlay.options.ogc = true; + overlay.options.output_direction = if clockwise { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + let shapes = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(shapes.len(), 1); + assert_eq!(shapes[0].len(), 2); + assert_eq!(double_area(&shapes[0][0]) < 0, clockwise); + assert_eq!( + double_area(&shapes[0][1]) > 0, + clockwise, + "shift={shift}, shapes={shapes:?}" + ); + let area: i64 = shapes.iter().flatten().map(|p| double_area(p)).sum(); + assert_eq!(area, if clockwise { -10 } else { 10 }); + } +} + +#[test] +fn ogc_leftmost_touching_hole_has_opposite_winding() { + check_hole_direction(false); +} + +#[test] +fn clockwise_ogc_leftmost_touching_hole_has_opposite_winding() { + check_hole_direction(true); +} + +#[test] +fn ogc_leftmost_touching_hole_survives_nonzero_roundtrip() { + let mut overlay = Overlay::with_contour(&touching_hole(), &[]); + overlay.options.ogc = true; + let shapes = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); + let contours: Vec<_> = shapes.into_iter().flatten().collect(); + let result = Overlay::with_contours(&contours, &[]).overlay(OverlayRule::Subject, FillRule::NonZero); + let area: i64 = result.iter().flatten().map(|p| double_area(p)).sum(); + assert_eq!( + area, 10, + "area must remain 6 - 1 after reusing OGC output: {result:?}" + ); +} + +#[test] +fn ordinary_extraction_preserves_leftmost_touching_hole_area() { + let shapes = + Overlay::with_contour(&touching_hole(), &[]).overlay(OverlayRule::Subject, FillRule::NonZero); + let area: i64 = shapes.iter().flatten().map(|p| double_area(p)).sum(); + assert_eq!(area, 10); +} diff --git a/iOverlay/tests/ogc_traversal_tests.rs b/iOverlay/tests/ogc_traversal_tests.rs new file mode 100644 index 00000000..f082e193 --- /dev/null +++ b/iOverlay/tests/ogc_traversal_tests.rs @@ -0,0 +1,92 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::Overlay; +use i_overlay::core::overlay_rule::OverlayRule; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +#[test] +fn ogc_contour_marking_completes_a_closed_tour() { + const CHILD_ENV: &str = "I_OVERLAY_OGC_CLOSED_TOUR_CHILD"; + if std::env::var_os(CHILD_ENV).is_some() { + let paths = [ + [ + [8, -14], + [-12, -13], + [7, -10], + [1, -3], + [-14, 8], + [6, -16], + [8, 20], + [7, -20], + [-7, 6], + [11, -6], + ], + [ + [-17, -2], + [-11, -15], + [-18, 2], + [-6, 19], + [15, 3], + [-2, -19], + [-6, -4], + [11, -14], + [17, 10], + [-19, -17], + ], + [ + [8, -18], + [-8, -11], + [9, 3], + [11, -5], + [-16, 1], + [-1, -1], + [-2, 13], + [0, -11], + [-17, -18], + [-18, 12], + ], + ] + .map(|path| path.map(|p| IntPoint::new(p[0], p[1])).to_vec()); + let mut overlay = Overlay::with_contours(&paths, &[]); + overlay.options.ogc = true; + assert!( + !overlay + .overlay(OverlayRule::Subject, FillRule::EvenOdd) + .is_empty() + ); + return; + } + + // An unreachable final edge can keep the marking loop running forever. + // Run the regression in a child so failure does not hang the test suite. + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "ogc_contour_marking_completes_a_closed_tour", + "--nocapture", + ]) + .env(CHILD_ENV, "1") + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + loop { + if child.try_wait().unwrap().is_some() { + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + return; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("OGC extraction of 30 input vertices did not finish within 3 seconds"); + } + std::thread::sleep(Duration::from_millis(10)); + } +} diff --git a/iOverlay/tests/outline_identity_tests.rs b/iOverlay/tests/outline_identity_tests.rs new file mode 100644 index 00000000..8523ca7f --- /dev/null +++ b/iOverlay/tests/outline_identity_tests.rs @@ -0,0 +1,140 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{ContourDirection, Overlay}; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::float::overlay::OverlayOptions; +use i_overlay::mesh::outline::offset::OutlineOffset; +use i_overlay::mesh::style::OutlineStyle; + +type Shapes = Vec>>; + +#[test] +fn zero_outline_preserves_touching_triangular_hole() { + let source = vec![ + vec![ + [-1.0, 0.0], + [0.0, -3.0], + [1.0, -4.0], + [1.0, -5.0], + [2.0, -5.0], + [1.0, -3.0], + [1.0, -2.0], + [2.0, -1.0], + [0.0, 1.0], + ], + vec![[-1.0, 0.0], [0.0, 0.0], [0.0, -2.0]], + ]; + let mut options = OverlayOptions::default(); + options.ogc = true; + options.clean_result = false; + let result = source + .outline_custom_fixed_scale(&OutlineStyle::new(0.0), options, 100.0) + .unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].len(), 2, "missing touching hole: {result:?}"); +} + +fn canonical(mut shapes: Shapes) -> Shapes { + for shape in &mut shapes { + for path in shape.iter_mut() { + let start = (0..path.len()) + .min_by(|&a, &b| { + (0..path.len()) + .map(|j| path[(a + j) % path.len()]) + .cmp((0..path.len()).map(|j| path[(b + j) % path.len()])) + }) + .unwrap(); + path.rotate_left(start); + } + shape[1..].sort(); + } + shapes.sort(); + shapes +} + +#[test] +fn ogc_output_preserves_geometry_through_zero_outline() { + let mut seed = 0xd2a1_8934_71bc_2259_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 41) as i32 - 20 + }; + for case in 0..1000 { + let paths: Vec> = (0..1 + case % 5) + .map(|_| { + (0..3 + case % 10) + .map(|_| IntPoint::new(next(), next())) + .collect() + }) + .collect(); + let mut overlay = Overlay::with_contours(&paths, &[]); + overlay.options.ogc = true; + let source = overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); + for shape in &source { + for (index, path) in shape.iter().enumerate() { + let area: i64 = path + .iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| i64::from(a.x) * i64::from(b.y) - i64::from(a.y) * i64::from(b.x)) + .sum(); + assert_eq!( + area > 0, + index == 0, + "invalid source winding: case={case}, path={path:?}" + ); + } + } + let float: Vec>> = source + .iter() + .map(|s| { + s.iter() + .map(|p| p.iter().map(|p| [p.x as f64, p.y as f64]).collect()) + .collect() + }) + .collect(); + for clockwise in [false, true] { + let mut options = OverlayOptions::default(); + options.ogc = true; + options.clean_result = case % 2 == 0; + options.output_direction = if clockwise { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + let actual = float + .outline_custom_fixed_scale(&OutlineStyle::new(0.0), options, 100.0) + .unwrap(); + let actual = actual + .iter() + .map(|s| { + s.iter() + .map(|p| { + p.iter() + .map(|p| { + assert!( + (p[0] - p[0].round()).abs() < 1e-8 + && (p[1] - p[1].round()).abs() < 1e-8 + ); + IntPoint::new(p[0].round() as i32, p[1].round() as i32) + }) + .collect() + }) + .collect() + }) + .collect(); + let mut expected = source.clone(); + if clockwise { + for path in expected.iter_mut().flatten() { + path.reverse(); + } + } + let actual = canonical(actual); + let expected = canonical(expected); + if actual != expected { + let missing: Vec<_> = expected.iter().filter(|s| !actual.contains(s)).collect(); + let extra: Vec<_> = actual.iter().filter(|s| !expected.contains(s)).collect(); + panic!("case={case}, clockwise={clockwise}, missing={missing:?}, extra={extra:?}"); + } + } + } +} From 445ebe4faf1c0f81c8c4d9395809b961dd6d7488 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Fri, 11 Sep 2026 21:39:28 +0300 Subject: [PATCH 15/44] more ocg tests --- iOverlay/tests/ogc_hole_orientation_tests.rs | 151 +++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/iOverlay/tests/ogc_hole_orientation_tests.rs b/iOverlay/tests/ogc_hole_orientation_tests.rs index d2402fa4..71e3e0ab 100644 --- a/iOverlay/tests/ogc_hole_orientation_tests.rs +++ b/iOverlay/tests/ogc_hole_orientation_tests.rs @@ -2,6 +2,7 @@ use i_float::int::point::IntPoint; use i_overlay::core::fill_rule::FillRule; use i_overlay::core::overlay::{ContourDirection, Overlay}; use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::solver::Solver; fn touching_hole() -> Vec { // Outer area 6, hole area 1. The hole touches the leftmost outer vertex. @@ -73,3 +74,153 @@ fn ordinary_extraction_preserves_leftmost_touching_hole_area() { let area: i64 = shapes.iter().flatten().map(|p| double_area(p)).sum(); assert_eq!(area, 10); } + +fn holes_at_leftmost_vertex(count: usize) -> Vec> { + let mut contours = vec![vec![ + IntPoint::new(0, 0), + IntPoint::new(48, -48), + IntPoint::new(48, 48), + ]]; + for i in 0..count { + let bottom = -20 + 5 * i as i32; + let top = bottom + 1 + (i % 3) as i32; + // Disjoint angular sectors, separated by filled wedges. Every hole + // shares exactly the outer contour's unique leftmost vertex. + contours.push(vec![ + IntPoint::new(0, 0), + IntPoint::new(24, top), + IntPoint::new(24, bottom), + ]); + } + contours +} + +fn canonical_contours(mut contours: Vec>) -> Vec> { + for contour in &mut contours { + let start = contour.iter().enumerate().min_by_key(|(_, p)| **p).unwrap().0; + contour.rotate_left(start); + } + contours.sort(); + contours +} + +fn check_shared_leftmost_holes(input: &[Vec], expected: &[Vec], case: &str) { + let expected_area: i64 = expected.iter().map(|p| double_area(p)).sum(); + for solver in [Solver::LIST, Solver::TREE, Solver::FRAG] { + for fill in [FillRule::EvenOdd, FillRule::NonZero] { + for clockwise in [false, true] { + let mut overlay = Overlay::with_contours(input, &[]); + overlay.options.ogc = true; + overlay.options.output_direction = if clockwise { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + overlay.solver = solver; + let shapes = overlay.overlay(OverlayRule::Subject, fill); + let context = format!( + "{case}, solver={:?}, fill={fill:?}, clockwise={clockwise}", + solver.strategy + ); + assert_eq!(shapes.len(), 1, "{context}: {shapes:?}"); + assert_eq!(shapes[0].len(), expected.len(), "{context}: {shapes:?}"); + for (i, contour) in shapes[0].iter().enumerate() { + assert_eq!( + double_area(contour) < 0, + clockwise != (i > 0), + "{context}: {contour:?}" + ); + } + let area: i64 = shapes[0].iter().map(|p| double_area(p)).sum(); + assert_eq!( + area, + if clockwise { -expected_area } else { expected_area }, + "{context}" + ); + let mut expected_direction = expected.to_vec(); + if clockwise { + for contour in &mut expected_direction { + contour.reverse(); + } + } + // Exact boundaries catch missing, duplicated, or incorrectly split holes. + assert_eq!( + canonical_contours(shapes[0].clone()), + canonical_contours(expected_direction), + "{context}" + ); + + let mut reused = Overlay::with_contours(&shapes[0], &[]); + reused.options.ogc = true; + reused.solver = solver; + let roundtrip = reused.overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(roundtrip.len(), 1, "roundtrip: {context}"); + assert_eq!( + canonical_contours(roundtrip[0].clone()), + canonical_contours(expected.to_vec()), + "roundtrip: {context}" + ); + } + } + } +} + +#[test] +fn ogc_multiple_holes_share_the_outer_leftmost_vertex() { + for count in [2, 3, 8] { + let expected = holes_at_leftmost_vertex(count); + for reversed in [false, true] { + for shift in 0..expected.len() { + let mut input = expected.clone(); + input.rotate_left(shift); + if reversed { + input.reverse(); + } + // Change the starting vertex of each separate contour as well. + for (i, contour) in input.iter_mut().enumerate() { + contour.rotate_left((shift + i) % 3); + } + check_shared_leftmost_holes( + &input, + &expected, + &format!("holes={count}, shift={shift}, reversed_order={reversed}"), + ); + } + } + } +} + +#[test] +fn ogc_one_contour_visits_multiple_holes_at_its_leftmost_vertex() { + for count in [2, 3, 8] { + let expected = holes_at_leftmost_vertex(count); + for reversed_hole_order in [false, true] { + let mut holes = expected[1..].to_vec(); + if reversed_hole_order { + holes.reverse(); + } + let mut path = vec![IntPoint::new(0, 0)]; + for hole in &holes { + path.extend_from_slice(&hole[1..]); + path.push(IntPoint::new(0, 0)); + } + path.extend_from_slice(&expected[0][1..]); + for reversed_winding in [false, true] { + let mut input = path.clone(); + if reversed_winding { + input.reverse(); + } + for shift in 0..input.len() { + check_shared_leftmost_holes( + &[input.clone()], + &expected, + &format!( + "holes={count}, shift={shift}, reversed_hole_order={reversed_hole_order}, reversed_winding={reversed_winding}" + ), + ); + input.rotate_left(1); + } + } + } + } +} From 12309e0707df0862abdf364bb1095c8ad39b66e3 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Fri, 11 Sep 2026 23:04:20 +0300 Subject: [PATCH 16/44] more tests --- iOverlay/tests/clip_coverage_tests.rs | 101 +++++++++++++ iOverlay/tests/clip_oblique_tests.rs | 116 +++++++++++++++ iOverlay/tests/edge_data_geometry_tests.rs | 135 ++++++++++++++++++ iOverlay/tests/ogc_area_filter_tests.rs | 78 ++++++++++ iOverlay/tests/outline_cell_oracle_tests.rs | 101 +++++++++++++ iOverlay/tests/predicate_cell_oracle_tests.rs | 113 +++++++++++++++ iOverlay/tests/resolved_geometry_tests.rs | 103 +++++++++++++ 7 files changed, 747 insertions(+) create mode 100644 iOverlay/tests/clip_oblique_tests.rs create mode 100644 iOverlay/tests/edge_data_geometry_tests.rs create mode 100644 iOverlay/tests/ogc_area_filter_tests.rs create mode 100644 iOverlay/tests/outline_cell_oracle_tests.rs create mode 100644 iOverlay/tests/predicate_cell_oracle_tests.rs create mode 100644 iOverlay/tests/resolved_geometry_tests.rs diff --git a/iOverlay/tests/clip_coverage_tests.rs b/iOverlay/tests/clip_coverage_tests.rs index 9930decb..6caca141 100644 --- a/iOverlay/tests/clip_coverage_tests.rs +++ b/iOverlay/tests/clip_coverage_tests.rs @@ -89,3 +89,104 @@ fn grid_clipping_preserves_directed_coverage() { } } } + +#[test] +fn clipping_overlapping_contours_uses_the_resolved_boundary() { + let mut seed = 0x173b_d902_aa9f_4ee1_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + (seed >> 32) as u32 + }; + let mut lines = Vec::new(); + for k in -1..=9 { + for line in [ + [IntPoint::new(-1, k), IntPoint::new(9, k)], + [IntPoint::new(k, -1), IntPoint::new(k, 9)], + ] { + lines.push(line); + lines.push([line[1], line[0]]); + } + } + let input = unit_edges(&lines.iter().map(|p| p.to_vec()).collect::>()); + for case in 0..300 { + let mut contours = Vec::new(); + let mut winding = [[0_i32; 8]; 8]; + for _ in 0..1 + case % 8 { + let (x0, y0) = ((next() % 8) as usize, (next() % 8) as usize); + let (x1, y1) = ( + x0 + 1 + next() as usize % (8 - x0), + y0 + 1 + next() as usize % (8 - y0), + ); + let sign = if next() % 2 == 0 { 1 } else { -1 }; + for column in &mut winding[x0..x1] { + for count in &mut column[y0..y1] { + *count += sign; + } + } + let mut contour = vec![ + IntPoint::new(x0 as i32, y0 as i32), + IntPoint::new(x1 as i32, y0 as i32), + IntPoint::new(x1 as i32, y1 as i32), + IntPoint::new(x0 as i32, y1 as i32), + ]; + if sign < 0 { + contour.reverse(); + } + contours.push(contour); + } + for fill in [ + FillRule::EvenOdd, + FillRule::NonZero, + FillRule::Positive, + FillRule::Negative, + ] { + let filled = |x: i32, y: i32| { + let count = if (0..8).contains(&x) && (0..8).contains(&y) { + winding[x as usize][y as usize] + } else { + 0 + }; + match fill { + FillRule::EvenOdd => count % 2 != 0, + FillRule::NonZero => count != 0, + FillRule::Positive => count > 0, + FillRule::Negative => count < 0, + } + }; + for boundary_included in [false, true] { + for invert in [false, true] { + let expected: BTreeSet<_> = input + .iter() + .copied() + .filter(|&(ax, ay, bx, by)| { + let (x, y) = (ax.min(bx), ay.min(by)); + let (a, b) = if ay == by { + (filled(x, y), filled(x, y - 1)) + } else { + (filled(x, y), filled(x - 1, y)) + }; + // A boundary separates a filled cell from an empty one. + // An input edge with filled cells on both sides is interior. + let inside = if boundary_included { a || b } else { a && b }; + inside != invert + }) + .collect(); + let mut overlay = StringOverlay::with_shape_contours(&contours); + overlay.add_string_lines(&lines); + let actual = overlay.clip_string_lines( + fill, + ClipRule { + invert, + boundary_included, + }, + ); + assert_eq!( + unit_edges(&actual), + expected, + "case={case}, fill={fill:?}, invert={invert}, boundary={boundary_included}, contours={contours:?}" + ); + } + } + } + } +} diff --git a/iOverlay/tests/clip_oblique_tests.rs b/iOverlay/tests/clip_oblique_tests.rs new file mode 100644 index 00000000..06b7b12a --- /dev/null +++ b/iOverlay/tests/clip_oblique_tests.rs @@ -0,0 +1,116 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::solver::Solver; +use i_overlay::string::clip::ClipRule; +use i_overlay::string::overlay::StringOverlay; +use std::collections::BTreeSet; + +type Edge = (i64, i64, i64, i64); +fn edges(paths: &[Vec>], origin: i64) -> BTreeSet { + let mut result = BTreeSet::new(); + let inverse = |p: IntPoint| { + let (x, y) = (p.x - origin, p.y + origin); + assert_eq!( + (3 * x + 4 * y) % 25, + 0, + "off-lattice output {p:?}, origin={origin}" + ); + assert_eq!( + (-4 * x + 3 * y) % 25, + 0, + "off-lattice output {p:?}, origin={origin}" + ); + ((3 * x + 4 * y) / 25, (-4 * x + 3 * y) / 25) + }; + for path in paths { + for pair in path.windows(2) { + let (mut a, b) = (inverse(pair[0]), inverse(pair[1])); + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + assert!(dx != 0 || dy != 0); + assert!(dx == 0 || dy == 0 || dx == dy, "unexpected segment {a:?}..{b:?}"); + let step = (dx.signum(), dy.signum()); + while a != b { + let end = (a.0 + step.0, a.1 + step.1); + assert!( + result.insert((a.0, a.1, end.0, end.1)), + "duplicated directed segment" + ); + a = end; + } + } + } + result +} + +#[test] +fn oblique_clipping_preserves_exact_coverage_at_large_translations() { + let mut seed = 0x6817_f121_29ae_0912_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 17) as i64 - 3 + }; + for case in 0..200 { + let mut raw = Vec::new(); + for i in 0..40 { + let (x, y, t) = (next(), next(), next()); + let a = (x, y); + let b = match i % 3 { + 0 => (t, y), + 1 => (x, t), + _ => (x + t, y + t), + }; + raw.push((a, b)); + } + for origin in [0, 1_000_000_000, 1_i64 << 60, -(1_i64 << 60)] { + let transform = + |(x, y): (i64, i64)| IntPoint::new(origin + 3 * x - 4 * y, -origin + 4 * x + 3 * y); + let contour: Vec<_> = [(0, 0), (10, 0), (10, 10), (0, 10)] + .into_iter() + .map(transform) + .collect(); + let lines: Vec<_> = raw.iter().map(|&(a, b)| [transform(a), transform(b)]).collect(); + let input: Vec<_> = lines + .iter() + .filter(|l| l[0] != l[1]) + .map(|l| l.to_vec()) + .collect(); + // Coincident input edges represent one directed edge in the string overlay. + let mut all = BTreeSet::new(); + for path in input { + all.extend(edges(&[path], origin)); + } + for boundary_included in [false, true] { + for invert in [false, true] { + let expected: BTreeSet<_> = all + .iter() + .copied() + .filter(|&(ax, ay, bx, by)| { + let (x, y) = (ax + bx, ay + by); + let inside = if boundary_included { + (0..=20).contains(&x) && (0..=20).contains(&y) + } else { + (1..20).contains(&x) && (1..20).contains(&y) + }; + inside != invert + }) + .collect(); + let mut overlay = StringOverlay::with_shape_contour(&contour); + overlay.add_string_lines(&lines); + let result = overlay.clip_string_lines_with_solver( + FillRule::NonZero, + ClipRule { + invert, + boundary_included, + }, + [Solver::LIST, Solver::TREE, Solver::FRAG][case % 3], + ); + assert_eq!( + edges(&result, origin), + expected, + "case={case}, origin={origin}, invert={invert}, boundary={boundary_included}" + ); + } + } + } + } +} diff --git a/iOverlay/tests/edge_data_geometry_tests.rs b/iOverlay/tests/edge_data_geometry_tests.rs new file mode 100644 index 00000000..0001c796 --- /dev/null +++ b/iOverlay/tests/edge_data_geometry_tests.rs @@ -0,0 +1,135 @@ +use i_float::int::number::int::IntNumber; +use i_float::int::point::IntPoint; +use i_overlay::core::edge_data::{EdgeDataMerge, EdgeDataSplit, OverlayEdgeData}; +use i_overlay::core::edge_overlay::{EdgeOverlay, InputEdge}; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{ContourDirection, ShapeType}; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::solver::Solver; +use i_overlay::segm::boolean::ShapeCountBoolean; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Endpoints { + a: [i64; 2], + b: [i64; 2], +} + +#[derive(Default)] +struct Calls { + split: usize, + merge: usize, + reverse: usize, +} + +fn point(p: IntPoint) -> [i64; 2] { + // Test coordinates are small integers, exactly representable in f64. + [p.x.to_f64() as i64, p.y.to_f64() as i64] +} + +impl OverlayEdgeData for Endpoints { + type Store = Calls; + + fn reversed(self, store: &mut Calls) -> Self { + store.reverse += 1; + Self { a: self.b, b: self.a } + } + + fn split(self, ctx: EdgeDataSplit, store: &mut Calls) -> (Self, Self) { + store.split += 1; + let (a, p, b) = (point(ctx.a), point(ctx.p), point(ctx.b)); + assert_eq!( + self, + Self { a, b }, + "split context must match the current edge data" + ); + assert!(a != p && p != b); + (Self { a, b: p }, Self { a: p, b }) + } + + fn merge(ctx: EdgeDataMerge, store: &mut Calls) -> Self { + store.merge += 1; + assert_eq!( + ctx.lhs_data, ctx.rhs_data, + "merged edges must share directed endpoints" + ); + ctx.lhs_data + } +} + +fn edges(path: &[IntPoint]) -> Vec> { + path.iter() + .zip(path.iter().cycle().skip(1)) + .map(|(&a, &b)| InputEdge { + a, + b, + data: Endpoints { + a: point(a), + b: point(b), + }, + }) + .collect() +} + +#[test] +fn edge_data_tracks_splits_merges_and_output_direction() { + let subject = [[0, 0], [10, 0], [10, 10], [0, 10]].map(|p| IntPoint::new(p[0], p[1])); + let clip = [[5, 0], [15, 0], [15, 10], [5, 10]].map(|p| IntPoint::new(p[0], p[1])); + let mut seed = 0x1359_8472_fa99_217b_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 41) as i32 - 20 + }; + for case in 0..501 { + let (a, b) = if case == 0 { + (subject.to_vec(), clip.to_vec()) + } else { + ( + (0..3 + case % 8).map(|_| IntPoint::new(next(), next())).collect(), + (0..3 + case % 9).map(|_| IntPoint::new(next(), next())).collect(), + ) + }; + let mut overlay = EdgeOverlay::new(a.len() + b.len()); + overlay.solver = [Solver::LIST, Solver::TREE, Solver::FRAG][case % 3]; + overlay.add_edges(edges(&a), ShapeType::Subject); + overlay.add_edges(edges(&b), ShapeType::Clip); + for clockwise in [false, true] { + overlay.options.output_direction = if clockwise { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + for rule in [ + OverlayRule::Union, + OverlayRule::Intersect, + OverlayRule::Difference, + OverlayRule::Xor, + ] { + let shapes = overlay.build_vector_shapes(rule, FillRule::EvenOdd); + for edge in shapes.iter().flatten().flatten() { + assert_eq!( + edge.data, + Endpoints { + a: point(edge.a), + b: point(edge.b) + }, + "case={case}, clockwise={clockwise}, rule={rule:?}, edge={edge:?}" + ); + } + for edge in overlay.build_vectors(rule, FillRule::EvenOdd) { + assert_eq!( + edge.data, + Endpoints { + a: point(edge.a), + b: point(edge.b) + }, + "flat vector: case={case}, edge={edge:?}" + ); + } + } + } + if case == 0 { + let store = overlay.data_store(); + assert!(store.split > 0 && store.merge > 0 && store.reverse > 0); + } + } +} diff --git a/iOverlay/tests/ogc_area_filter_tests.rs b/iOverlay/tests/ogc_area_filter_tests.rs new file mode 100644 index 00000000..fea10b5b --- /dev/null +++ b/iOverlay/tests/ogc_area_filter_tests.rs @@ -0,0 +1,78 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{ContourDirection, Overlay}; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::solver::Solver; + +type Shapes = Vec>>; + +fn area(path: &[IntPoint]) -> u64 { + path.iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| i64::from(a.x) * i64::from(b.y) - i64::from(a.y) * i64::from(b.x)) + .sum::() + .unsigned_abs() + / 2 +} + +fn canonical(mut shapes: Shapes) -> Shapes { + for shape in &mut shapes { + for path in shape.iter_mut() { + let start = (0..path.len()) + .min_by(|&a, &b| { + (0..path.len()) + .map(|j| path[(a + j) % path.len()]) + .cmp((0..path.len()).map(|j| path[(b + j) % path.len()])) + }) + .unwrap(); + path.rotate_left(start); + } + shape[1..].sort(); + } + shapes.sort(); + shapes +} + +#[test] +fn ogc_area_filter_matches_filtering_resolved_contours() { + let mut seed = 0x728a_13dd_8741_990e_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 41) as i32 - 20 + }; + for case in 0..500 { + let paths: Vec> = (0..1 + case % 4) + .map(|_| (0..3 + case % 8).map(|_| IntPoint::new(next(), next())).collect()) + .collect(); + for clockwise in [false, true] { + let mut overlay = Overlay::with_contours(&paths, &[]); + overlay.options.ogc = true; + overlay.options.output_direction = if clockwise { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + overlay.solver = [Solver::LIST, Solver::TREE, Solver::FRAG][case % 3]; + let all = overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); + // The last zero threshold also checks reuse after an empty extraction. + for threshold in [0, 1, 2, 5, 10, 50, u64::MAX, 0] { + let expected: Shapes = all + .iter() + .filter(|s| area(&s[0]) >= threshold) + .map(|s| { + let mut shape = vec![s[0].clone()]; + shape.extend(s.iter().skip(1).filter(|p| area(p) >= threshold).cloned()); + shape + }) + .collect(); + overlay.options.min_output_area = threshold; + let actual = overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); + assert_eq!( + canonical(actual), + canonical(expected), + "case={case}, clockwise={clockwise}, threshold={threshold}, input={paths:?}" + ); + } + } + } +} diff --git a/iOverlay/tests/outline_cell_oracle_tests.rs b/iOverlay/tests/outline_cell_oracle_tests.rs new file mode 100644 index 00000000..51e24873 --- /dev/null +++ b/iOverlay/tests/outline_cell_oracle_tests.rs @@ -0,0 +1,101 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::Overlay; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::mesh::outline::offset::OutlineOffset; +use i_overlay::mesh::style::{LineJoin, OutlineStyle}; + +fn contains(shapes: &[Vec>], x: f64, y: f64) -> bool { + shapes.iter().any(|shape| { + let mut inside = false; + for path in shape { + for (a, b) in path.iter().zip(path.iter().cycle().skip(1)) { + assert!( + a[0] == b[0] || a[1] == b[1], + "miter offsets of orthogonal shapes must stay orthogonal" + ); + if (a[1] > y) != (b[1] > y) && a[0] > x { + inside = !inside; + } + } + } + inside + }) +} + +#[test] +fn orthogonal_miter_offsets_match_cell_dilation_and_erosion() { + let mut seed = 0x2917_8b42_357a_1de3_u64; + for case in 0..200 { + let mut cells = [[false; 6]; 6]; + let mut contours = Vec::new(); + for x in 0..6 { + for y in 0..6 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + cells[x][y] = (seed >> 32) % 100 < 20 + case % 65; + if cells[x][y] { + let (x, y) = (4 * x as i32, 4 * y as i32); + contours.push(vec![ + IntPoint::new(x, y), + IntPoint::new(x + 4, y), + IntPoint::new(x + 4, y + 4), + IntPoint::new(x, y + 4), + ]); + } + } + } + let mut overlay = Overlay::with_contours(&contours, &[]); + overlay.options.ogc = true; + let subject = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); + let source: Vec>> = subject + .iter() + .map(|s| { + s.iter() + .map(|p| p.iter().map(|p| [p.x as f64, p.y as f64]).collect()) + .collect() + }) + .collect(); + let occupied = |x: i32, y: i32| { + let (cx, cy) = (x.div_euclid(4), y.div_euclid(4)); + (0..6).contains(&cx) && (0..6).contains(&cy) && cells[cx as usize][cy as usize] + }; + for offset in [-2_i32, -1, 1, 2] { + let style = OutlineStyle::new(offset as f64).line_join(LineJoin::Miter(0.1)); + let result = source.outline_fixed_scale(&style, 100.0).unwrap(); + let mut expected_area = 0; + for x in -3..27 { + for y in -3..27 { + let r = offset.abs(); + // A miter offset of an orthogonal boundary is dilation or + // erosion by an axis-aligned square. Half-unit samples avoid + // every output boundary and cover all unit cells exactly. + let expected = if offset > 0 { + (-r..=r).any(|dx| (-r..=r).any(|dy| occupied(x + dx, y + dy))) + } else { + (-r..=r).all(|dx| (-r..=r).all(|dy| occupied(x + dx, y + dy))) + }; + expected_area += i32::from(expected); + assert_eq!( + contains(&result, x as f64 + 0.5, y as f64 + 0.5), + expected, + "case={case}, offset={offset}, sample=({x}.5,{y}.5), cells={cells:?}, source={source:?}, result={result:?}" + ); + } + } + let area: f64 = result + .iter() + .flatten() + .map(|p| { + p.iter() + .zip(p.iter().cycle().skip(1)) + .map(|(a, b)| 0.5 * (a[0] * b[1] - a[1] * b[0])) + .sum::() + }) + .sum(); + assert!( + (area - expected_area as f64).abs() < 1e-6, + "case={case}, offset={offset}, area={area}, expected={expected_area}" + ); + } + } +} diff --git a/iOverlay/tests/predicate_cell_oracle_tests.rs b/iOverlay/tests/predicate_cell_oracle_tests.rs new file mode 100644 index 00000000..824d58d3 --- /dev/null +++ b/iOverlay/tests/predicate_cell_oracle_tests.rs @@ -0,0 +1,113 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::ShapeType; +use i_overlay::core::relate::PredicateOverlay; +use i_overlay::core::solver::Solver; +use std::collections::BTreeSet; + +fn filled(count: i32, rule: FillRule) -> bool { + match rule { + FillRule::EvenOdd => count % 2 != 0, + FillRule::NonZero => count != 0, + FillRule::Positive => count > 0, + FillRule::Negative => count < 0, + } +} + +fn closure(cells: &BTreeSet<(i32, i32)>) -> (BTreeSet<(i32, i32)>, BTreeSet<(i32, i32, i32, i32)>) { + let mut vertices = BTreeSet::new(); + let mut edges = BTreeSet::new(); + for &(x, y) in cells { + vertices.extend([(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)]); + edges.extend([ + (x, y, x + 1, y), + (x, y + 1, x + 1, y + 1), + (x, y, x, y + 1), + (x + 1, y, x + 1, y + 1), + ]); + } + (vertices, edges) +} + +#[test] +fn predicates_match_filled_cells_after_contour_cancellation() { + let mut seed = 0x3587_89ad_182a_4b12_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + (seed >> 32) as usize + }; + for case in 0..1200 { + let mut contours = [Vec::new(), Vec::new()]; + let mut counts = [[0_i32; 64]; 2]; + for side in 0..2 { + for _ in 0..1 + next() % 12 { + let x = next() % 8; + let y = next() % 8; + let x1 = x + 1 + next() % (8 - x); + let y1 = y + 1 + next() % (8 - y); + let sign = if next() % 2 == 0 { 1 } else { -1 }; + let mut path = vec![ + IntPoint::new(x as i32, y as i32), + IntPoint::new(x1 as i32, y as i32), + IntPoint::new(x1 as i32, y1 as i32), + IntPoint::new(x as i32, y1 as i32), + ]; + if sign < 0 { + path.reverse(); + } + let copies = 1 + next() % 3; + for _ in 0..copies { + contours[side].push(path.clone()); + } + for yy in y..y1 { + for xx in x..x1 { + counts[side][8 * yy + xx] += sign * copies as i32; + } + } + } + } + let mut overlay = PredicateOverlay::new(0); + overlay.solver = [Solver::LIST, Solver::TREE, Solver::FRAG][case % 3]; + overlay.add_contours(&contours[0], ShapeType::Subject); + overlay.add_contours(&contours[1], ShapeType::Clip); + for rule in [ + FillRule::EvenOdd, + FillRule::NonZero, + FillRule::Positive, + FillRule::Negative, + FillRule::EvenOdd, + ] { + overlay.fill_rule = rule; + let cells: Vec> = counts + .iter() + .map(|c| { + c.iter() + .enumerate() + .filter(|(_, n)| filled(**n, rule)) + .map(|(i, _)| ((i % 8) as i32, (i / 8) as i32)) + .collect() + }) + .collect(); + let (av, ae) = closure(&cells[0]); + let (bv, be) = closure(&cells[1]); + let interior = !cells[0].is_disjoint(&cells[1]); + let intersects = !av.is_disjoint(&bv); + let point = intersects && !interior && ae.is_disjoint(&be); + let within = !cells[0].is_empty() && cells[0].is_subset(&cells[1]); + let expected = (intersects, interior, intersects && !interior, point, within); + for repeat in 0..2 { + let actual = ( + overlay.intersects(), + overlay.interiors_intersect(), + overlay.touches(), + overlay.point_intersects(), + overlay.within(), + ); + assert_eq!( + actual, expected, + "case={case}, rule={rule:?}, repeat={repeat}, contours={contours:?}" + ); + } + } + } +} diff --git a/iOverlay/tests/resolved_geometry_tests.rs b/iOverlay/tests/resolved_geometry_tests.rs new file mode 100644 index 00000000..fbb5294a --- /dev/null +++ b/iOverlay/tests/resolved_geometry_tests.rs @@ -0,0 +1,103 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::Overlay; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::point_location::IntPointContainment; +use i_overlay::core::solver::Solver; + +type Shapes = Vec>>; +fn cross(a: IntPoint, b: IntPoint, c: IntPoint) -> i64 { + (b.x - a.x) as i64 * (c.y - a.y) as i64 - (b.y - a.y) as i64 * (c.x - a.x) as i64 +} +fn oracle(shapes: &Shapes, p: IntPoint) -> Option { + let mut result = false; + for shape in shapes { + let mut inside = false; + for path in shape { + for (&a, &b) in path.iter().zip(path.iter().cycle().skip(1)) { + let c = cross(a, b, p); + if c == 0 + && p.x >= a.x.min(b.x) + && p.x <= a.x.max(b.x) + && p.y >= a.y.min(b.y) + && p.y <= a.y.max(b.y) + { + return None; + } + if (a.y > p.y) != (b.y > p.y) && ((c > 0) == (b.y > a.y)) { + inside = !inside; + } + } + } + result |= inside; + } + Some(result) +} + +#[test] +fn dense_boolean_output_has_resolved_edges_and_correct_point_locations() { + let mut seed = 0x1792_8721_8eda_38fe_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 101) as i32 - 50 + }; + for case in 0..240 { + let a: Vec<_> = (0..30 + case % 70) + .map(|_| IntPoint::new(next(), next())) + .collect(); + let b: Vec<_> = (0..20 + case % 40) + .map(|_| IntPoint::new(next(), next())) + .collect(); + let mut overlay = Overlay::with_contour(&a, &b); + overlay.solver = [Solver::LIST, Solver::TREE, Solver::FRAG][case % 3]; + overlay.options.ogc = true; + let rule = [ + OverlayRule::Union, + OverlayRule::Intersect, + OverlayRule::Difference, + OverlayRule::Xor, + ][case % 4]; + let fill = [ + FillRule::EvenOdd, + FillRule::NonZero, + FillRule::Positive, + FillRule::Negative, + ][(case / 4) % 4]; + let shapes = overlay.overlay(rule, fill); + let edges: Vec<_> = shapes + .iter() + .flatten() + .flat_map(|p| p.iter().copied().zip(p.iter().copied().cycle().skip(1))) + .collect(); + for (i, &(a, b)) in edges.iter().enumerate() { + assert_ne!(a, b, "zero length output edge, case={case}"); + for &(c, d) in &edges[i + 1..] { + let proper = cross(a, b, c).signum() * cross(a, b, d).signum() < 0 + && cross(c, d, a).signum() * cross(c, d, b).signum() < 0; + assert!( + !proper, + "unresolved crossing, case={case}, edges={a:?}..{b:?}, {c:?}..{d:?}" + ); + } + } + let mut queries = Vec::new(); + let mut expected = Vec::new(); + for y in (-52..=52).step_by(3) { + for x in (-52..=52).step_by(3) { + let p = IntPoint::new(x, y); + if let Some(inside) = oracle(&shapes, p) { + queries.push(p); + expected.push(inside); + } + } + } + let actual = shapes.contains_points(&queries); + if let Some(i) = actual.iter().zip(&expected).position(|(a, b)| a != b) { + panic!( + "case={case}, point={:?}, actual={}, expected={}", + queries[i], actual[i], expected[i] + ); + } + } +} + From 503c8ff73cd7698dc540740e93da4aa5231538f8 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Sun, 13 Sep 2026 13:08:53 +0300 Subject: [PATCH 17/44] split mesh into int and float add basic Bevel impl --- iOverlay/README.md | 14 +- iOverlay/src/float/clip.rs | 2 +- iOverlay/src/float/overlay.rs | 2 +- iOverlay/src/float/relate.rs | 2 +- iOverlay/src/float/scale.rs | 2 +- iOverlay/src/float/simplify.rs | 2 +- iOverlay/src/float/single.rs | 2 +- iOverlay/src/float/slice.rs | 2 +- iOverlay/src/float/string_overlay.rs | 2 +- iOverlay/src/mesh/{ => float}/math.rs | 0 iOverlay/src/mesh/{ => float}/miter.rs | 0 iOverlay/src/mesh/float/mod.rs | 8 + .../src/mesh/{ => float}/outline/builder.rs | 16 +- .../mesh/{ => float}/outline/builder_join.rs | 6 +- iOverlay/src/mesh/{ => float}/outline/mod.rs | 1 - .../src/mesh/{ => float}/outline/offset.rs | 14 +- .../src/mesh/{ => float}/outline/section.rs | 0 iOverlay/src/mesh/{ => float}/rotator.rs | 2 +- .../src/mesh/{ => float}/stroke/builder.rs | 16 +- .../mesh/{ => float}/stroke/builder_cap.rs | 8 +- .../mesh/{ => float}/stroke/builder_join.rs | 8 +- iOverlay/src/mesh/{ => float}/stroke/mod.rs | 0 .../src/mesh/{ => float}/stroke/offset.rs | 20 +- .../src/mesh/{ => float}/stroke/section.rs | 2 +- iOverlay/src/mesh/{ => float}/style.rs | 0 .../{ => float}/variable_stroke/builder.rs | 16 +- .../mesh/{ => float}/variable_stroke/debug.rs | 0 .../mesh/{ => float}/variable_stroke/mod.rs | 0 .../{ => float}/variable_stroke/offset.rs | 16 +- .../{ => float}/variable_stroke/resource.rs | 4 +- .../{ => float}/variable_stroke/section.rs | 6 +- .../mesh/{ => float}/variable_stroke/style.rs | 0 iOverlay/src/mesh/int/mod.rs | 3 + iOverlay/src/mesh/int/outline/bounds.rs | 38 +++ iOverlay/src/mesh/int/outline/build.rs | 89 ++++++ iOverlay/src/mesh/int/outline/builder.rs | 65 ++++ iOverlay/src/mesh/int/outline/builder_join.rs | 40 +++ iOverlay/src/mesh/int/outline/mod.rs | 6 + iOverlay/src/mesh/int/outline/offset.rs | 114 +++++++ iOverlay/src/mesh/int/outline/section.rs | 38 +++ iOverlay/src/mesh/int/style.rs | 55 ++++ iOverlay/src/mesh/mod.rs | 10 +- iOverlay/src/mesh/subject.rs | 20 +- iOverlay/src/mesh/{outline => }/uniq_iter.rs | 2 +- iOverlay/tests/critical_hypotheses_tests.rs | 4 +- iOverlay/tests/float_point_adapter.rs | 2 +- iOverlay/tests/int_outline_bounds_tests.rs | 133 ++++++++ iOverlay/tests/int_outline_tests.rs | 283 ++++++++++++++++++ iOverlay/tests/mesh_scale_tests.rs | 6 +- iOverlay/tests/outline_boundary_tests.rs | 4 +- iOverlay/tests/outline_cell_oracle_tests.rs | 4 +- iOverlay/tests/outline_grid_area_tests.rs | 4 +- iOverlay/tests/outline_identity_tests.rs | 4 +- iOverlay/tests/outline_pipeline_tests.rs | 6 +- iOverlay/tests/stroke_cap_precision_tests.rs | 4 +- iOverlay/tests/stroke_coverage_tests.rs | 12 +- iOverlay/tests/stroke_direction_tests.rs | 4 +- iOverlay/tests/variable_stroke_stress.rs | 4 +- 58 files changed, 1006 insertions(+), 121 deletions(-) rename iOverlay/src/mesh/{ => float}/math.rs (100%) rename iOverlay/src/mesh/{ => float}/miter.rs (100%) create mode 100644 iOverlay/src/mesh/float/mod.rs rename iOverlay/src/mesh/{ => float}/outline/builder.rs (94%) rename iOverlay/src/mesh/{ => float}/outline/builder_join.rs (98%) rename iOverlay/src/mesh/{ => float}/outline/mod.rs (80%) rename iOverlay/src/mesh/{ => float}/outline/offset.rs (99%) rename iOverlay/src/mesh/{ => float}/outline/section.rs (100%) rename iOverlay/src/mesh/{ => float}/rotator.rs (97%) rename iOverlay/src/mesh/{ => float}/stroke/builder.rs (95%) rename iOverlay/src/mesh/{ => float}/stroke/builder_cap.rs (96%) rename iOverlay/src/mesh/{ => float}/stroke/builder_join.rs (98%) rename iOverlay/src/mesh/{ => float}/stroke/mod.rs (100%) rename iOverlay/src/mesh/{ => float}/stroke/offset.rs (98%) rename iOverlay/src/mesh/{ => float}/stroke/section.rs (98%) rename iOverlay/src/mesh/{ => float}/style.rs (100%) rename iOverlay/src/mesh/{ => float}/variable_stroke/builder.rs (98%) rename iOverlay/src/mesh/{ => float}/variable_stroke/debug.rs (100%) rename iOverlay/src/mesh/{ => float}/variable_stroke/mod.rs (100%) rename iOverlay/src/mesh/{ => float}/variable_stroke/offset.rs (97%) rename iOverlay/src/mesh/{ => float}/variable_stroke/resource.rs (98%) rename iOverlay/src/mesh/{ => float}/variable_stroke/section.rs (97%) rename iOverlay/src/mesh/{ => float}/variable_stroke/style.rs (100%) create mode 100644 iOverlay/src/mesh/int/mod.rs create mode 100644 iOverlay/src/mesh/int/outline/bounds.rs create mode 100644 iOverlay/src/mesh/int/outline/build.rs create mode 100644 iOverlay/src/mesh/int/outline/builder.rs create mode 100644 iOverlay/src/mesh/int/outline/builder_join.rs create mode 100644 iOverlay/src/mesh/int/outline/mod.rs create mode 100644 iOverlay/src/mesh/int/outline/offset.rs create mode 100644 iOverlay/src/mesh/int/outline/section.rs create mode 100644 iOverlay/src/mesh/int/style.rs rename iOverlay/src/mesh/{outline => }/uniq_iter.rs (98%) create mode 100644 iOverlay/tests/int_outline_bounds_tests.rs create mode 100644 iOverlay/tests/int_outline_tests.rs diff --git a/iOverlay/README.md b/iOverlay/README.md index 0465743d..39535641 100644 --- a/iOverlay/README.md +++ b/iOverlay/README.md @@ -490,12 +490,18 @@ println!("result: {:?}", result); ## Buffering +Floating-point outline, stroke, and variable-width stroke APIs live under `mesh::float`. +Integer outline lives under `mesh::int::outline`, with styles in `mesh::int::style`. +Integer outline currently supports bevel joins; miter and round joins return `UnsupportedJoin`. +Use `validate_outline(&style)` for an optional conservative coordinate-range check. +Integer construction asserts the bounds in debug builds and trusts the caller in release builds. + ### Offsetting a Path Path Example ```rust -use i_overlay::mesh::stroke::offset::StrokeOffset; -use i_overlay::mesh::style::{LineCap, LineJoin, StrokeStyle}; +use i_overlay::mesh::float::stroke::offset::StrokeOffset; +use i_overlay::mesh::float::style::{LineCap, LineJoin, StrokeStyle}; let path = [ [ 2.0, 1.0], @@ -523,8 +529,8 @@ println!("result: {:?}", shapes); Path Example ```rust -use i_overlay::mesh::outline::offset::OutlineOffset; -use i_overlay::mesh::style::{LineJoin, OutlineStyle}; +use i_overlay::mesh::float::outline::offset::OutlineOffset; +use i_overlay::mesh::float::style::{LineJoin, OutlineStyle}; let shape = vec![ vec![ diff --git a/iOverlay/src/float/clip.rs b/iOverlay/src/float/clip.rs index a438d002..5844e1ac 100644 --- a/iOverlay/src/float/clip.rs +++ b/iOverlay/src/float/clip.rs @@ -6,7 +6,7 @@ use crate::float::string_overlay::FloatStringOverlay; use crate::string::clip::ClipRule; use i_float::float::compatible::FloatPointCompatible; use i_shape::base::data::Paths; -use i_shape::source::resource::ShapeResource; +use i_shape::source::float::resource::ShapeResource; /// Trait for clipping float string paths by float shapes. /// diff --git a/iOverlay/src/float/overlay.rs b/iOverlay/src/float/overlay.rs index 3bb3141f..8c55fba6 100644 --- a/iOverlay/src/float/overlay.rs +++ b/iOverlay/src/float/overlay.rs @@ -9,7 +9,7 @@ use crate::core::overlay_rule::OverlayRule; use crate::core::solver::Solver; use crate::float::graph::FloatOverlayGraph; use crate::float::hierarchy::FloatFlatShapeHierarchy; -use crate::i_shape::source::resource::ShapeResource; +use crate::i_shape::source::float::resource::ShapeResource; use core::marker::PhantomData; use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; diff --git a/iOverlay/src/float/relate.rs b/iOverlay/src/float/relate.rs index 0145741d..45394cce 100644 --- a/iOverlay/src/float/relate.rs +++ b/iOverlay/src/float/relate.rs @@ -5,7 +5,7 @@ use crate::core::relate::PredicateOverlay; use crate::core::solver::Solver; use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; -use i_shape::source::resource::ShapeResource; +use i_shape::source::float::resource::ShapeResource; /// Float-coordinate wrapper for spatial predicate evaluation. /// diff --git a/iOverlay/src/float/scale.rs b/iOverlay/src/float/scale.rs index 7476bc73..bd46cc51 100644 --- a/iOverlay/src/float/scale.rs +++ b/iOverlay/src/float/scale.rs @@ -9,7 +9,7 @@ use i_float::adapter::{FloatPointAdapter, FloatPointAdapterScaleError}; use i_float::float::compatible::FloatPointCompatible; use i_float::float::number::FloatNumber; use i_shape::base::data::Shapes; -use i_shape::source::resource::ShapeResource; +use i_shape::source::float::resource::ShapeResource; #[derive(Debug, Clone, Copy)] pub enum FixedScaleOverlayError { diff --git a/iOverlay/src/float/simplify.rs b/iOverlay/src/float/simplify.rs index 295aeb9a..026e8866 100644 --- a/iOverlay/src/float/simplify.rs +++ b/iOverlay/src/float/simplify.rs @@ -5,7 +5,7 @@ use crate::core::solver::Solver; use crate::float::overlay::{FloatOverlay, OverlayOptions}; use i_float::float::compatible::FloatPointCompatible; use i_shape::base::data::Shapes; -use i_shape::source::resource::ShapeResource; +use i_shape::source::float::resource::ShapeResource; /// Trait `Simplify` provides a method to simplify geometric shapes by reducing the number of points in contours or shapes /// while preserving overall shape and topology. The method applies a minimum area threshold and a build rule to diff --git a/iOverlay/src/float/single.rs b/iOverlay/src/float/single.rs index a1cfa463..931600e7 100644 --- a/iOverlay/src/float/single.rs +++ b/iOverlay/src/float/single.rs @@ -4,7 +4,7 @@ use crate::core::overlay_rule::OverlayRule; use crate::float::overlay::FloatOverlay; use i_float::float::compatible::FloatPointCompatible; use i_shape::base::data::Shapes; -use i_shape::source::resource::ShapeResource; +use i_shape::source::float::resource::ShapeResource; /// Trait `SingleFloatOverlay` provides methods for overlay operations between various geometric entities. /// This trait supports boolean operations on contours, shapes, and collections of shapes, using customizable overlay and build rules. diff --git a/iOverlay/src/float/slice.rs b/iOverlay/src/float/slice.rs index 4a732606..fc04e880 100644 --- a/iOverlay/src/float/slice.rs +++ b/iOverlay/src/float/slice.rs @@ -7,7 +7,7 @@ use crate::float::string_overlay::FloatStringOverlay; use crate::string::rule::StringRule; use i_float::float::compatible::FloatPointCompatible; use i_shape::base::data::Shapes; -use i_shape::source::resource::ShapeResource; +use i_shape::source::float::resource::ShapeResource; /// The `FloatSlice` trait provides methods to slice geometric shapes using a given path or set of paths, /// allowing for boolean operations based on the specified build rule. diff --git a/iOverlay/src/float/string_overlay.rs b/iOverlay/src/float/string_overlay.rs index 53bd4113..ce363432 100644 --- a/iOverlay/src/float/string_overlay.rs +++ b/iOverlay/src/float/string_overlay.rs @@ -9,7 +9,7 @@ use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; use i_shape::base::data::Paths; use i_shape::float::adapter::ShapeToFloat; -use i_shape::source::resource::ShapeResource; +use i_shape::source::float::resource::ShapeResource; /// The `FloatStringOverlay` struct is a builder for overlaying geometric shapes by converting /// floating-point geometry to integer space. It provides methods for adding paths and shapes, diff --git a/iOverlay/src/mesh/math.rs b/iOverlay/src/mesh/float/math.rs similarity index 100% rename from iOverlay/src/mesh/math.rs rename to iOverlay/src/mesh/float/math.rs diff --git a/iOverlay/src/mesh/miter.rs b/iOverlay/src/mesh/float/miter.rs similarity index 100% rename from iOverlay/src/mesh/miter.rs rename to iOverlay/src/mesh/float/miter.rs diff --git a/iOverlay/src/mesh/float/mod.rs b/iOverlay/src/mesh/float/mod.rs new file mode 100644 index 00000000..11c5bac6 --- /dev/null +++ b/iOverlay/src/mesh/float/mod.rs @@ -0,0 +1,8 @@ +//! Mesh operations on floating-point input geometry. +pub(crate) mod math; +mod miter; +pub mod outline; +mod rotator; +pub mod stroke; +pub mod style; +pub mod variable_stroke; diff --git a/iOverlay/src/mesh/outline/builder.rs b/iOverlay/src/mesh/float/outline/builder.rs similarity index 94% rename from iOverlay/src/mesh/outline/builder.rs rename to iOverlay/src/mesh/float/outline/builder.rs index 3272a4dc..12ed9a6c 100644 --- a/iOverlay/src/mesh/outline/builder.rs +++ b/iOverlay/src/mesh/float/outline/builder.rs @@ -1,9 +1,9 @@ -use crate::mesh::math::Math; -use crate::mesh::outline::builder_join::JoinBuilder; -use crate::mesh::outline::builder_join::{BevelJoinBuilder, MiterJoinBuilder, RoundJoinBuilder}; -use crate::mesh::outline::section::OffsetSection; -use crate::mesh::outline::uniq_iter::{UniqueSegment, UniqueSegmentsIter}; -use crate::mesh::style::LineJoin; +use crate::mesh::float::math::Math; +use crate::mesh::float::outline::builder_join::JoinBuilder; +use crate::mesh::float::outline::builder_join::{BevelJoinBuilder, MiterJoinBuilder, RoundJoinBuilder}; +use crate::mesh::float::outline::section::OffsetSection; +use crate::mesh::float::style::LineJoin; +use crate::mesh::uniq_iter::{UniqueSegment, UniqueSegmentsIter}; use crate::segm::boolean::ShapeCountBoolean; use crate::segm::segment::Segment; use alloc::boxed::Box; @@ -219,8 +219,8 @@ impl VecPushSome for Vec { #[cfg(test)] mod tests { - use crate::mesh::outline::builder::OutlineBuilder; - use crate::mesh::style::LineJoin; + use crate::mesh::float::outline::builder::OutlineBuilder; + use crate::mesh::float::style::LineJoin; use crate::segm::boolean::ShapeCountBoolean; use crate::segm::segment::Segment; use alloc::vec::Vec; diff --git a/iOverlay/src/mesh/outline/builder_join.rs b/iOverlay/src/mesh/float/outline/builder_join.rs similarity index 98% rename from iOverlay/src/mesh/outline/builder_join.rs rename to iOverlay/src/mesh/float/outline/builder_join.rs index 2dac64e7..81efb1e0 100644 --- a/iOverlay/src/mesh/outline/builder_join.rs +++ b/iOverlay/src/mesh/float/outline/builder_join.rs @@ -1,6 +1,6 @@ -use crate::mesh::miter::Miter; -use crate::mesh::outline::section::OffsetSection; -use crate::mesh::rotator::Rotator; +use crate::mesh::float::miter::Miter; +use crate::mesh::float::outline::section::OffsetSection; +use crate::mesh::float::rotator::Rotator; use crate::segm::boolean::ShapeCountBoolean; use crate::segm::segment::Segment; use alloc::vec::Vec; diff --git a/iOverlay/src/mesh/outline/mod.rs b/iOverlay/src/mesh/float/outline/mod.rs similarity index 80% rename from iOverlay/src/mesh/outline/mod.rs rename to iOverlay/src/mesh/float/outline/mod.rs index b161db59..adf73534 100644 --- a/iOverlay/src/mesh/outline/mod.rs +++ b/iOverlay/src/mesh/float/outline/mod.rs @@ -2,4 +2,3 @@ mod builder; mod builder_join; pub mod offset; mod section; -mod uniq_iter; diff --git a/iOverlay/src/mesh/outline/offset.rs b/iOverlay/src/mesh/float/outline/offset.rs similarity index 99% rename from iOverlay/src/mesh/outline/offset.rs rename to iOverlay/src/mesh/float/outline/offset.rs index c46e72dd..4222bda8 100644 --- a/iOverlay/src/mesh/outline/offset.rs +++ b/iOverlay/src/mesh/float/outline/offset.rs @@ -6,8 +6,8 @@ use crate::core::overlay::{ContourDirection, Overlay}; use crate::core::overlay_rule::OverlayRule; use crate::float::overlay::OverlayOptions; use crate::float::scale::FixedScaleOverlayError; -use crate::mesh::outline::builder::OutlineBuilder; -use crate::mesh::style::OutlineStyle; +use crate::mesh::float::outline::builder::OutlineBuilder; +use crate::mesh::float::style::OutlineStyle; use alloc::vec; use alloc::vec::Vec; use i_float::adapter::FloatPointAdapter; @@ -24,7 +24,7 @@ use i_shape::float::adapter::ShapesToFloat; use i_shape::float::despike::DeSpikeContour; use i_shape::float::int_area::IntArea; use i_shape::float::simple::SimplifyContour; -use i_shape::source::resource::ShapeResource; +use i_shape::source::float::resource::ShapeResource; /// Trait for offsetting float contours and shapes. /// @@ -34,8 +34,8 @@ use i_shape::source::resource::ShapeResource; /// # Example /// /// ``` -/// use i_overlay::mesh::outline::offset::OutlineOffset; -/// use i_overlay::mesh::style::OutlineStyle; +/// use i_overlay::mesh::float::outline::offset::OutlineOffset; +/// use i_overlay::mesh::float::style::OutlineStyle; /// /// let path = [[0.0, 0.0], [10.0, 0.0], [0.0, 10.0]]; /// let style = OutlineStyle::new(1.0); @@ -568,8 +568,8 @@ where mod tests { use crate::core::fill_rule::FillRule; use crate::float::simplify::SimplifyShape; - use crate::mesh::outline::offset::OutlineOffset; - use crate::mesh::style::{LineJoin, OutlineStyle}; + use crate::mesh::float::outline::offset::OutlineOffset; + use crate::mesh::float::style::{LineJoin, OutlineStyle}; use alloc::vec; use alloc::vec::Vec; use core::f32::consts::PI; diff --git a/iOverlay/src/mesh/outline/section.rs b/iOverlay/src/mesh/float/outline/section.rs similarity index 100% rename from iOverlay/src/mesh/outline/section.rs rename to iOverlay/src/mesh/float/outline/section.rs diff --git a/iOverlay/src/mesh/rotator.rs b/iOverlay/src/mesh/float/rotator.rs similarity index 97% rename from iOverlay/src/mesh/rotator.rs rename to iOverlay/src/mesh/float/rotator.rs index 8e660abe..767e1780 100644 --- a/iOverlay/src/mesh/rotator.rs +++ b/iOverlay/src/mesh/float/rotator.rs @@ -42,7 +42,7 @@ impl Rotator { #[cfg(test)] mod tests { - use crate::mesh::rotator::Rotator; + use crate::mesh::float::rotator::Rotator; use core::f64::consts::PI; #[test] diff --git a/iOverlay/src/mesh/stroke/builder.rs b/iOverlay/src/mesh/float/stroke/builder.rs similarity index 95% rename from iOverlay/src/mesh/stroke/builder.rs rename to iOverlay/src/mesh/float/stroke/builder.rs index c124f2cf..92a075b4 100644 --- a/iOverlay/src/mesh/stroke/builder.rs +++ b/iOverlay/src/mesh/float/stroke/builder.rs @@ -1,7 +1,9 @@ -use crate::mesh::stroke::builder_cap::CapBuilder; -use crate::mesh::stroke::builder_join::{BevelJoinBuilder, JoinBuilder, MiterJoinBuilder, RoundJoinBuilder}; -use crate::mesh::stroke::section::{Section, SectionToSegment}; -use crate::mesh::style::{LineJoin, StrokeStyle}; +use crate::mesh::float::stroke::builder_cap::CapBuilder; +use crate::mesh::float::stroke::builder_join::{ + BevelJoinBuilder, JoinBuilder, MiterJoinBuilder, RoundJoinBuilder, +}; +use crate::mesh::float::stroke::section::{Section, SectionToSegment}; +use crate::mesh::float::style::{LineJoin, StrokeStyle}; use crate::segm::boolean::ShapeCountBoolean; use crate::segm::segment::Segment; use alloc::boxed::Box; @@ -233,8 +235,8 @@ impl, P: FloatPointCompatible, I: IntNumber> Builder JoinBuilder for RoundJoinBuild #[cfg(test)] mod tests { use super::{BevelJoinBuilder, JoinBuilder, MiterJoinBuilder, RoundJoinBuilder}; - use crate::mesh::stroke::section::Section; + use crate::mesh::float::stroke::section::Section; use crate::segm::boolean::ShapeCountBoolean; use crate::segm::segment::Segment; use alloc::vec::Vec; diff --git a/iOverlay/src/mesh/stroke/mod.rs b/iOverlay/src/mesh/float/stroke/mod.rs similarity index 100% rename from iOverlay/src/mesh/stroke/mod.rs rename to iOverlay/src/mesh/float/stroke/mod.rs diff --git a/iOverlay/src/mesh/stroke/offset.rs b/iOverlay/src/mesh/float/stroke/offset.rs similarity index 98% rename from iOverlay/src/mesh/stroke/offset.rs rename to iOverlay/src/mesh/float/stroke/offset.rs index 989bd1d2..11e80dd8 100644 --- a/iOverlay/src/mesh/stroke/offset.rs +++ b/iOverlay/src/mesh/float/stroke/offset.rs @@ -4,10 +4,9 @@ use crate::core::overlay::Overlay; use crate::core::overlay_rule::OverlayRule; use crate::float::overlay::OverlayOptions; use crate::float::scale::FixedScaleOverlayError; -use crate::i_shape::source::resource::ShapeResource; -use crate::mesh::stroke::builder::StrokeBuilder; -use crate::mesh::stroke::offset::vec::Vec; -use crate::mesh::style::StrokeStyle; +use crate::mesh::float::stroke::builder::StrokeBuilder; +use crate::mesh::float::stroke::offset::vec::Vec; +use crate::mesh::float::style::StrokeStyle; use alloc::vec; use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; @@ -22,6 +21,7 @@ use i_shape::flat::float::FloatFlatContoursBuffer; use i_shape::float::adapter::ShapesToFloat; use i_shape::float::despike::DeSpikeContour; use i_shape::float::simple::SimplifyContour; +use i_shape::source::float::resource::ShapeResource; /// Trait for generating stroke outlines from float paths. /// @@ -31,8 +31,8 @@ use i_shape::float::simple::SimplifyContour; /// # Example /// /// ``` -/// use i_overlay::mesh::stroke::offset::StrokeOffset; -/// use i_overlay::mesh::style::StrokeStyle; +/// use i_overlay::mesh::float::stroke::offset::StrokeOffset; +/// use i_overlay::mesh::float::style::StrokeStyle; /// /// let path = [[0.0, 0.0], [10.0, 0.0]]; /// let style = StrokeStyle::new(2.0); @@ -585,8 +585,8 @@ where #[cfg(test)] mod tests { - use crate::mesh::stroke::offset::StrokeOffset; - use crate::mesh::style::{LineCap, LineJoin, StrokeStyle}; + use crate::mesh::float::stroke::offset::StrokeOffset; + use crate::mesh::float::style::{LineCap, LineJoin, StrokeStyle}; use alloc::rc::Rc; use alloc::vec; use alloc::vec::Vec; @@ -599,9 +599,7 @@ mod tests { // The template has zero width and height, but extends 100 radii // from the endpoint after rotation and translation. let cap = LineCap::Custom(Rc::from(vec![[100.0, 0.0]])); - let style = StrokeStyle::new(2.0) - .start_cap(cap.clone()) - .end_cap(cap); + let style = StrokeStyle::new(2.0).start_cap(cap.clone()).end_cap(cap); let shapes = path.stroke(style, false); diff --git a/iOverlay/src/mesh/stroke/section.rs b/iOverlay/src/mesh/float/stroke/section.rs similarity index 98% rename from iOverlay/src/mesh/stroke/section.rs rename to iOverlay/src/mesh/float/stroke/section.rs index d647b0f4..321d02ca 100644 --- a/iOverlay/src/mesh/stroke/section.rs +++ b/iOverlay/src/mesh/float/stroke/section.rs @@ -1,4 +1,4 @@ -use crate::mesh::math::Math; +use crate::mesh::float::math::Math; use crate::segm::boolean::ShapeCountBoolean; use crate::segm::segment::Segment; use alloc::vec::Vec; diff --git a/iOverlay/src/mesh/style.rs b/iOverlay/src/mesh/float/style.rs similarity index 100% rename from iOverlay/src/mesh/style.rs rename to iOverlay/src/mesh/float/style.rs diff --git a/iOverlay/src/mesh/variable_stroke/builder.rs b/iOverlay/src/mesh/float/variable_stroke/builder.rs similarity index 98% rename from iOverlay/src/mesh/variable_stroke/builder.rs rename to iOverlay/src/mesh/float/variable_stroke/builder.rs index d8b00f68..9fd16b13 100644 --- a/iOverlay/src/mesh/variable_stroke/builder.rs +++ b/iOverlay/src/mesh/float/variable_stroke/builder.rs @@ -1,6 +1,6 @@ -use crate::mesh::rotator::Rotator; -use crate::mesh::variable_stroke::section::{RadiusTrend, Section}; -use crate::mesh::variable_stroke::style::{StrokeVertex, VariableStrokeStyle}; +use crate::mesh::float::rotator::Rotator; +use crate::mesh::float::variable_stroke::section::{RadiusTrend, Section}; +use crate::mesh::float::variable_stroke::style::{StrokeVertex, VariableStrokeStyle}; use crate::segm::boolean::ShapeCountBoolean; use crate::segm::segment::Segment; use alloc::vec::Vec; @@ -14,7 +14,7 @@ use i_float::int::number::uint::UIntNumber; use i_float::int::number::wide_int::WideIntNumber; #[cfg(feature = "variable_stroke_debug")] -use crate::mesh::variable_stroke::{VariableStrokeDebugEdge, VariableStrokeDebugEdgeKind}; +use crate::mesh::float::variable_stroke::{VariableStrokeDebugEdge, VariableStrokeDebugEdgeKind}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Cap { @@ -587,10 +587,10 @@ impl SegmentBuilder<'_, P, I> { mod tests { use super::{ArcSweep, Cap, SegmentBuilder, SubSegment, VariableStrokeBuilder}; #[cfg(feature = "variable_stroke_debug")] - use crate::mesh::variable_stroke::VariableStrokeDebugEdgeKind; - use crate::mesh::variable_stroke::offset::VariableStrokeOffset; - use crate::mesh::variable_stroke::section::Section; - use crate::mesh::variable_stroke::style::{StrokeVertex, VariableStrokeStyle}; + use crate::mesh::float::variable_stroke::VariableStrokeDebugEdgeKind; + use crate::mesh::float::variable_stroke::offset::VariableStrokeOffset; + use crate::mesh::float::variable_stroke::section::Section; + use crate::mesh::float::variable_stroke::style::{StrokeVertex, VariableStrokeStyle}; use crate::segm::boolean::ShapeCountBoolean; use crate::segm::segment::Segment; use alloc::vec; diff --git a/iOverlay/src/mesh/variable_stroke/debug.rs b/iOverlay/src/mesh/float/variable_stroke/debug.rs similarity index 100% rename from iOverlay/src/mesh/variable_stroke/debug.rs rename to iOverlay/src/mesh/float/variable_stroke/debug.rs diff --git a/iOverlay/src/mesh/variable_stroke/mod.rs b/iOverlay/src/mesh/float/variable_stroke/mod.rs similarity index 100% rename from iOverlay/src/mesh/variable_stroke/mod.rs rename to iOverlay/src/mesh/float/variable_stroke/mod.rs diff --git a/iOverlay/src/mesh/variable_stroke/offset.rs b/iOverlay/src/mesh/float/variable_stroke/offset.rs similarity index 97% rename from iOverlay/src/mesh/variable_stroke/offset.rs rename to iOverlay/src/mesh/float/variable_stroke/offset.rs index bc889d3b..4b389b46 100644 --- a/iOverlay/src/mesh/variable_stroke/offset.rs +++ b/iOverlay/src/mesh/float/variable_stroke/offset.rs @@ -4,9 +4,9 @@ use crate::core::overlay::Overlay; use crate::core::overlay_rule::OverlayRule; use crate::float::overlay::OverlayOptions; use crate::float::scale::FixedScaleOverlayError; -use crate::mesh::variable_stroke::builder::VariableStrokeBuilder; -use crate::mesh::variable_stroke::resource::VariableStrokeSource; -use crate::mesh::variable_stroke::style::VariableStrokeStyle; +use crate::mesh::float::variable_stroke::builder::VariableStrokeBuilder; +use crate::mesh::float::variable_stroke::resource::VariableStrokeSource; +use crate::mesh::float::variable_stroke::style::VariableStrokeStyle; use alloc::vec; use alloc::vec::Vec; use i_float::adapter::FloatPointAdapter; @@ -24,7 +24,7 @@ use i_shape::float::despike::DeSpikeContour; use i_shape::float::simple::SimplifyContour; #[cfg(feature = "variable_stroke_debug")] -use crate::mesh::variable_stroke::VariableStrokeDebugResult; +use crate::mesh::float::variable_stroke::VariableStrokeDebugResult; /// Builds round-cap, round-join strokes whose width is stored at each centerline vertex. pub trait VariableStrokeOffset

: VariableStrokeSource

@@ -413,16 +413,16 @@ where mod tests { use super::VariableStrokeOffset; use crate::float::overlay::OverlayOptions; - use crate::mesh::stroke::offset::StrokeOffset; - use crate::mesh::style::{LineCap, LineJoin, StrokeStyle}; - use crate::mesh::variable_stroke::{StrokeVertex, VariableStrokeStyle}; + use crate::mesh::float::stroke::offset::StrokeOffset; + use crate::mesh::float::style::{LineCap, LineJoin, StrokeStyle}; + use crate::mesh::float::variable_stroke::{StrokeVertex, VariableStrokeStyle}; use alloc::vec; use alloc::vec::Vec; use i_shape::flat::float::FloatFlatContoursBuffer; use i_shape::float::area::Area; #[cfg(feature = "variable_stroke_debug")] - use crate::mesh::variable_stroke::{VariableStrokeDebug, VariableStrokeDebugEdgeKind}; + use crate::mesh::float::variable_stroke::{VariableStrokeDebug, VariableStrokeDebugEdgeKind}; #[cfg(feature = "variable_stroke_debug")] #[test] diff --git a/iOverlay/src/mesh/variable_stroke/resource.rs b/iOverlay/src/mesh/float/variable_stroke/resource.rs similarity index 98% rename from iOverlay/src/mesh/variable_stroke/resource.rs rename to iOverlay/src/mesh/float/variable_stroke/resource.rs index cb70982b..4e9296c8 100644 --- a/iOverlay/src/mesh/variable_stroke/resource.rs +++ b/iOverlay/src/mesh/float/variable_stroke/resource.rs @@ -1,4 +1,4 @@ -use crate::mesh::variable_stroke::style::StrokeVertex; +use crate::mesh::float::variable_stroke::style::StrokeVertex; use alloc::vec::Vec; use i_float::float::compatible::FloatPointCompatible; @@ -187,7 +187,7 @@ impl<'b, P: FloatPointCompatible> VariableStrokeSource

for &'b [Vec Section

{ #[cfg(test)] mod tests { use super::{RadiusTrend, Section}; - use crate::mesh::variable_stroke::StrokeVertex; + use crate::mesh::float::variable_stroke::StrokeVertex; use i_float::adapter::FloatPointAdapter; use i_float::float::rect::FloatRect; diff --git a/iOverlay/src/mesh/variable_stroke/style.rs b/iOverlay/src/mesh/float/variable_stroke/style.rs similarity index 100% rename from iOverlay/src/mesh/variable_stroke/style.rs rename to iOverlay/src/mesh/float/variable_stroke/style.rs diff --git a/iOverlay/src/mesh/int/mod.rs b/iOverlay/src/mesh/int/mod.rs new file mode 100644 index 00000000..a5fea8c6 --- /dev/null +++ b/iOverlay/src/mesh/int/mod.rs @@ -0,0 +1,3 @@ +//! Mesh operations on integer input geometry. +pub mod outline; +pub mod style; diff --git a/iOverlay/src/mesh/int/outline/bounds.rs b/iOverlay/src/mesh/int/outline/bounds.rs new file mode 100644 index 00000000..71b6eaff --- /dev/null +++ b/iOverlay/src/mesh/int/outline/bounds.rs @@ -0,0 +1,38 @@ +use super::builder_join::{BevelJoinBuilder, JoinBuilder}; +use super::offset::IntOutlineError; +use crate::mesh::int::style::{IntLineJoin, IntOutlineStyle}; +use i_float::int::number::int::IntNumber; +use i_float::int::rect::IntRect; +use i_shape::source::int::resource::IntShapeResource; + +pub(super) fn validate + ?Sized>( + source: &S, + style: &IntOutlineStyle, +) -> Result<(), IntOutlineError> { + let padding = match style.join { + IntLineJoin::Bevel => BevelJoinBuilder + .additional_offset(style.outer_offset) + .max(BevelJoinBuilder.additional_offset(style.inner_offset)), + join => return Err(IntOutlineError::UnsupportedJoin(join)), + }; + let Some(rect) = IntRect::with_iter(source.iter_paths().flatten()) else { + return Ok(()); + }; + + // Expand in the wide type, including when an offset is I::MIN. Saturating + // at storage limits prevents wrapping during narrowing: these limits are + // themselves outside is_in_safe_range, so an oversized bound stays invalid. + let min = I::MIN.to_wide(); + let max = I::MAX.to_wide(); + let expanded = IntRect::new( + I::from_wide((rect.min_x.to_wide() - padding).max(min)), + I::from_wide((rect.max_x.to_wide() + padding).min(max)), + I::from_wide((rect.min_y.to_wide() - padding).max(min)), + I::from_wide((rect.max_y.to_wide() + padding).min(max)), + ); + if expanded.is_in_safe_range() { + Ok(()) + } else { + Err(IntOutlineError::CoordinateOutOfRange) + } +} diff --git a/iOverlay/src/mesh/int/outline/build.rs b/iOverlay/src/mesh/int/outline/build.rs new file mode 100644 index 00000000..56c91f24 --- /dev/null +++ b/iOverlay/src/mesh/int/outline/build.rs @@ -0,0 +1,89 @@ +use super::bounds; +use super::builder::OutlineBuilder; +use super::builder_join::{BevelJoinBuilder, JoinBuilder}; +use super::offset::IntOutlineError; +use crate::core::extract::BooleanExtractionBuffer; +use crate::core::fill_rule::FillRule; +use crate::core::integer::OverlayInt; +use crate::core::overlay::{ContourDirection, IntOverlayOptions, Overlay, ShapeType}; +use crate::core::overlay_rule::OverlayRule; +use crate::mesh::int::style::{IntLineJoin, IntOutlineStyle}; +use alloc::vec::Vec; +use i_float::int::number::uint::UIntNumber; +use i_float::int::number::wide_int::WideIntNumber; +use i_shape::flat::buffer::FlatContoursBuffer; +use i_shape::int::area::Area; +use i_shape::source::int::resource::IntShapeResource; + +pub(super) trait BuildOutlineOverlay: IntShapeResource { + fn build_overlay( + &self, + style: &IntOutlineStyle, + options: IntOverlayOptions, + ) -> Result, IntOutlineError> { + match style.join { + IntLineJoin::Bevel => { + debug_assert!( + bounds::validate(self, style).is_ok(), + "outline bounds exceed the safe coordinate range" + ); + Ok(self.build_overlay_with_builders( + options, + OutlineBuilder::new(style.outer_offset, BevelJoinBuilder), + OutlineBuilder::new(style.inner_offset, BevelJoinBuilder), + )) + } + join => Err(IntOutlineError::UnsupportedJoin(join)), + } + } + + fn build_overlay_with_builders>( + &self, + options: IntOverlayOptions, + outer_builder: OutlineBuilder, + inner_builder: OutlineBuilder, + ) -> Overlay { + let mut overlay = Overlay::new_custom(0, options, Default::default()); + let mut contour_options = options; + + // Contours below the threshold can join into a larger surviving result. + contour_options.min_output_area = I::WideUInt::ZERO; + + let mut contour_overlay = Overlay::new_custom(0, contour_options, Default::default()); + let mut segments = Vec::new(); + let mut extraction = BooleanExtractionBuffer::default(); + let mut contours = FlatContoursBuffer::default(); + + for path in self.iter_paths() { + if path.len() < 3 { + continue; + } + let area = path.area_two(); + if area == I::Wide::ZERO { + continue; + } + let (builder, direction, fill) = if area > I::Wide::ZERO { + ( + &outer_builder, + ContourDirection::CounterClockwise, + FillRule::Positive, + ) + } else { + (&inner_builder, ContourDirection::Clockwise, FillRule::Negative) + }; + segments.clear(); + builder.build(path, &mut segments); + + contour_overlay.options.output_direction = direction; + contour_overlay.clear(); + contour_overlay.add_segments(&segments); + if let Some(graph) = contour_overlay.build_graph_view(fill) { + graph.extract_contours_into(OverlayRule::Subject, &mut extraction, &mut contours); + overlay.add_flat_buffer(&contours, ShapeType::Subject); + } + } + overlay + } +} + +impl + ?Sized> BuildOutlineOverlay for S {} diff --git a/iOverlay/src/mesh/int/outline/builder.rs b/iOverlay/src/mesh/int/outline/builder.rs new file mode 100644 index 00000000..9e8d134f --- /dev/null +++ b/iOverlay/src/mesh/int/outline/builder.rs @@ -0,0 +1,65 @@ +use super::builder_join::JoinBuilder; +use super::section::OffsetSection; +use crate::mesh::subject::SubjectSegments; +use crate::mesh::uniq_iter::UniqueSegmentsIter; +use crate::segm::boolean::ShapeCountBoolean; +use crate::segm::segment::Segment; +use alloc::vec::Vec; +use i_float::int::number::int::IntNumber; +use i_float::int::number::wide_int::WideIntNumber; +use i_float::int::point::IntPoint; + +pub(super) struct OutlineBuilder> { + offset: I, + join_builder: J, +} + +impl> OutlineBuilder { + pub(super) fn new(offset: I, join_builder: J) -> Self { + Self { offset, join_builder } + } + + pub(super) fn build(&self, path: &[IntPoint], segments: &mut Vec>) { + let Some(mut iter) = UniqueSegmentsIter::new(path.iter().copied()) else { + return; + }; + let Some(first) = iter.next() else { + return; + }; + let first = OffsetSection::new(first, self.offset); + segments.push_non_degenerate(first.a_top, first.b_top); + let mut previous = first; + for segment in iter { + let next = OffsetSection::new(segment, self.offset); + segments.push_non_degenerate(next.a_top, next.b_top); + self.feed_join(&previous, &next, segments); + previous = next; + } + self.feed_join(&previous, &first, segments); + } + + #[inline] + fn feed_join( + &self, + previous: &OffsetSection, + next: &OffsetSection, + segments: &mut Vec>, + ) { + let vi = next.b - next.a; + let vp = previous.b - previous.a; + let cross = vi.cross_product(vp); + let outer_corner = if cross != I::Wide::ZERO { + (cross > I::Wide::ZERO) == (self.offset < I::ZERO) + } else { + vi.dot_product(vp) < I::Wide::ZERO + }; + if outer_corner { + if previous.b_top != next.a_top { + self.join_builder.add_join(previous, next, segments); + } + } else { + segments.push_non_degenerate(previous.b_top, previous.b); + segments.push_non_degenerate(next.a, next.a_top); + } + } +} diff --git a/iOverlay/src/mesh/int/outline/builder_join.rs b/iOverlay/src/mesh/int/outline/builder_join.rs new file mode 100644 index 00000000..3da3a201 --- /dev/null +++ b/iOverlay/src/mesh/int/outline/builder_join.rs @@ -0,0 +1,40 @@ +use super::section::OffsetSection; +use crate::mesh::subject::SubjectSegments; +use crate::segm::boolean::ShapeCountBoolean; +use crate::segm::segment::Segment; +use alloc::vec::Vec; +use i_float::int::number::int::IntNumber; +use i_float::int::number::wide_int::WideIntNumber; + +pub(super) trait JoinBuilder { + /// Connects the offset endpoints of an outer corner. The endpoints differ. + fn add_join( + &self, + previous: &OffsetSection, + next: &OffsetSection, + segments: &mut Vec>, + ); + + /// Nonnegative conservative padding, including temporary join points. + fn additional_offset(&self, offset: I) -> I::Wide; +} + +pub(super) struct BevelJoinBuilder; + +impl JoinBuilder for BevelJoinBuilder { + #[inline] + fn add_join( + &self, + previous: &OffsetSection, + next: &OffsetSection, + segments: &mut Vec>, + ) { + segments.push_non_degenerate(previous.b_top, next.a_top); + } + + #[inline] + fn additional_offset(&self, offset: I) -> I::Wide { + let offset = offset.to_wide(); + if offset < I::Wide::ZERO { -offset } else { offset } + } +} diff --git a/iOverlay/src/mesh/int/outline/mod.rs b/iOverlay/src/mesh/int/outline/mod.rs new file mode 100644 index 00000000..49285e91 --- /dev/null +++ b/iOverlay/src/mesh/int/outline/mod.rs @@ -0,0 +1,6 @@ +mod bounds; +mod build; +mod builder; +mod builder_join; +pub mod offset; +mod section; diff --git a/iOverlay/src/mesh/int/outline/offset.rs b/iOverlay/src/mesh/int/outline/offset.rs new file mode 100644 index 00000000..c2eaec2a --- /dev/null +++ b/iOverlay/src/mesh/int/outline/offset.rs @@ -0,0 +1,114 @@ +//! Integer outline construction. Bevel joins are implemented; angle-based joins +//! are explicit placeholders. Float outline remains a separate API for now. +use super::bounds; +use super::build::BuildOutlineOverlay; +use crate::core::fill_rule::FillRule; +use crate::core::integer::OverlayInt; +use crate::core::overlay::IntOverlayOptions; +use crate::core::overlay_rule::OverlayRule; +use crate::mesh::int::style::{IntLineJoin, IntOutlineStyle}; +use i_shape::flat::buffer::FlatContoursBuffer; +use i_shape::int::shape::IntShapes; +use i_shape::source::int::resource::IntShapeResource; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IntOutlineError { + /// Miter and round joins await integer angle support. + UnsupportedJoin(IntLineJoin), + /// The conservative expanded input bounds fail the coordinate-range check. + /// Returned by `validate_outline`, not by outline construction. + CoordinateOutOfRange, +} + +impl core::fmt::Display for IntOutlineError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::UnsupportedJoin(join) => write!(f, "integer {join:?} joins are not implemented"), + Self::CoordinateOutOfRange => f.write_str("outline coordinate exceeds the integer engine range"), + } + } +} + +impl core::error::Error for IntOutlineError {} + +/// Offsets integer contours with CCW outer boundaries and CW holes. +/// +/// Positive offsets expand filled geometry; negative offsets shrink it. Zero +/// offset still runs contour cleanup and union. Degenerate zero-area contours +/// are ignored. As with float outline, input winding determines outer/hole roles. +/// Input and constructed coordinates must satisfy +/// [`IntRect::is_in_safe_range`](i_float::int::rect::IntRect::is_in_safe_range). +/// Use [`Self::validate_outline`] for an optional conservative bounds check. +/// Construction checks this precondition only in debug builds; release builds +/// trust the caller. Contour signed double areas must also fit in `I::Wide` +/// (including repeated winding). +/// +/// Uses [`fast_normalize`](i_float::int::vector::IntVector::fast_normalize) +/// with about 6/14/30 bits of direction precision for i16/i32/i64. +/// Scaling a direction also scales its approximation error. +/// +/// ``` +/// use i_overlay::i_float::int::point::IntPoint; +/// use i_overlay::mesh::int::style::IntOutlineStyle; +/// use i_overlay::mesh::int::outline::offset::IntOutlineOffset; +/// +/// let contour = [ +/// IntPoint::new(0_i32, 0), IntPoint::new(16_384, 0), +/// IntPoint::new(16_384, 16_384), IntPoint::new(0, 16_384), +/// ]; +/// let style = IntOutlineStyle::new(1_024); +/// contour.validate_outline(&style)?; // Optional bounds check before construction. +/// let result = contour.outline(&style)?; +/// assert_eq!(result.len(), 1); +/// # Ok::<(), i_overlay::mesh::int::outline::offset::IntOutlineError>(()) +/// ``` +pub trait IntOutlineOffset: IntShapeResource { + /// Checks supported joins and the coordinate range of the prospective operation. + /// + /// Computes input bounds, expands by the maximum outer/inner join padding, + /// and checks `IntRect::is_in_safe_range`. Padding uses absolute offsets even + /// for shrinking outlines, since temporary edges must also stay in range. + /// The estimate is conservative and may reject geometry whose actual points + /// would fit. Empty input passes for supported joins. This does not validate + /// winding, topology, or the accumulated area of repeated winding. + fn validate_outline(&self, style: &IntOutlineStyle) -> Result<(), IntOutlineError> { + bounds::validate(self, style) + } + + fn outline(&self, style: &IntOutlineStyle) -> Result, IntOutlineError> { + self.outline_custom(style, Default::default()) + } + + /// Replaces output on success, including an empty result. Errors leave it unchanged. + fn outline_into( + &self, + style: &IntOutlineStyle, + output: &mut FlatContoursBuffer, + ) -> Result<(), IntOutlineError> { + self.outline_custom_into(style, Default::default(), output) + } + + fn outline_custom( + &self, + style: &IntOutlineStyle, + options: IntOverlayOptions, + ) -> Result, IntOutlineError> { + let mut overlay = self.build_overlay(style, options)?; + Ok(overlay.overlay(OverlayRule::Subject, FillRule::Positive)) + } + + /// Replaces output on success; errors leave it unchanged. + /// Area filtering is applied after contour union. + fn outline_custom_into( + &self, + style: &IntOutlineStyle, + options: IntOverlayOptions, + output: &mut FlatContoursBuffer, + ) -> Result<(), IntOutlineError> { + let mut overlay = self.build_overlay(style, options)?; + overlay.overlay_into(OverlayRule::Subject, FillRule::Positive, output); + Ok(()) + } +} + +impl + ?Sized> IntOutlineOffset for S {} diff --git a/iOverlay/src/mesh/int/outline/section.rs b/iOverlay/src/mesh/int/outline/section.rs new file mode 100644 index 00000000..223b292f --- /dev/null +++ b/iOverlay/src/mesh/int/outline/section.rs @@ -0,0 +1,38 @@ +use crate::mesh::uniq_iter::UniqueSegment; +use i_float::int::number::int::IntNumber; +use i_float::int::point::IntPoint; + +#[derive(Clone, Copy)] +pub(super) struct OffsetSection { + pub(super) a: IntPoint, + pub(super) b: IntPoint, + pub(super) a_top: IntPoint, + pub(super) b_top: IntPoint, +} + +impl OffsetSection { + pub(super) fn new(segment: UniqueSegment, offset: I) -> Self { + let (a, b) = (segment.a, segment.b); + if offset == I::ZERO { + return Self { + a, + b, + a_top: a, + b_top: b, + }; + } + let direction = (b - a) + .fast_normalize() + .expect("unique segments must have nonzero length"); + let scaled = direction.scale(offset); + + let dx = scaled.y; + let dy = -scaled.x; + let a_top = IntPoint::new(I::from_wide(a.x.to_wide() + dx), I::from_wide(a.y.to_wide() + dy)); + let b_top = IntPoint::new(I::from_wide(b.x.to_wide() + dx), I::from_wide(b.y.to_wide() + dy)); + + debug_assert!(a_top.is_in_safe_range() && b_top.is_in_safe_range()); + + Self { a, b, a_top, b_top } + } +} diff --git a/iOverlay/src/mesh/int/style.rs b/iOverlay/src/mesh/int/style.rs new file mode 100644 index 00000000..ce1af8e3 --- /dev/null +++ b/iOverlay/src/mesh/int/style.rs @@ -0,0 +1,55 @@ +use i_float::int::number::int::IntNumber; + +/// Join styles for integer mesh operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum IntLineJoin { + #[default] + Bevel, + /// Placeholder until integer angles and miter construction are implemented. + /// Requesting this join returns `IntOutlineError::UnsupportedJoin`. + Miter, + /// Placeholder until integer angles and arc construction are implemented. + /// Requesting this join returns `IntOutlineError::UnsupportedJoin`. + Round, +} + +/// Signed offsets in input coordinate units. Positive values expand the filled +/// shape: the outer boundary grows and holes shrink. Negative values reverse it. +/// Distances have no fractional part and are never automatically rescaled. +#[derive(Debug, Clone, Copy)] +pub struct IntOutlineStyle { + pub outer_offset: I, + pub inner_offset: I, + pub join: IntLineJoin, +} + +impl IntOutlineStyle { + pub fn new(offset: I) -> Self { + Self { + outer_offset: offset, + inner_offset: offset, + join: IntLineJoin::Bevel, + } + } + + pub fn offset(mut self, offset: I) -> Self { + self.outer_offset = offset; + self.inner_offset = offset; + self + } + + pub fn outer_offset(mut self, offset: I) -> Self { + self.outer_offset = offset; + self + } + + pub fn inner_offset(mut self, offset: I) -> Self { + self.inner_offset = offset; + self + } + + pub fn line_join(mut self, join: IntLineJoin) -> Self { + self.join = join; + self + } +} diff --git a/iOverlay/src/mesh/mod.rs b/iOverlay/src/mesh/mod.rs index 3350e09b..f925c139 100644 --- a/iOverlay/src/mesh/mod.rs +++ b/iOverlay/src/mesh/mod.rs @@ -1,9 +1,5 @@ -pub(crate) mod math; -mod miter; -pub mod outline; +pub mod float; +pub mod int; mod overlay; -mod rotator; -pub mod stroke; -pub mod style; mod subject; -pub mod variable_stroke; +mod uniq_iter; diff --git a/iOverlay/src/mesh/subject.rs b/iOverlay/src/mesh/subject.rs index 7e3f1793..11202c63 100644 --- a/iOverlay/src/mesh/subject.rs +++ b/iOverlay/src/mesh/subject.rs @@ -1,16 +1,28 @@ use crate::geom::x_segment::XSegment; use crate::segm::boolean::ShapeCountBoolean; use crate::segm::segment::Segment; +use alloc::vec::Vec; use i_float::int::number::int::IntNumber; use i_float::int::point::IntPoint; +pub(super) trait SubjectSegments { + /// Adds a subject segment only when its endpoints differ. + fn push_non_degenerate(&mut self, a: IntPoint, b: IntPoint); +} + +impl SubjectSegments for Vec> { + #[inline] + fn push_non_degenerate(&mut self, a: IntPoint, b: IntPoint) { + if a != b { + self.push(Segment::subject(a, b)); + } + } +} + impl Segment { #[inline] pub(crate) fn subject(p0: IntPoint, p1: IntPoint) -> Self { - debug_assert!( - p0 != p1, - "zero-length edges must be filtered before construction" - ); + debug_assert!(p0 != p1, "zero-length edges must be filtered before construction"); if p0 < p1 { Self { x_segment: XSegment { a: p0, b: p1 }, diff --git a/iOverlay/src/mesh/outline/uniq_iter.rs b/iOverlay/src/mesh/uniq_iter.rs similarity index 98% rename from iOverlay/src/mesh/outline/uniq_iter.rs rename to iOverlay/src/mesh/uniq_iter.rs index c4733201..104128b9 100644 --- a/iOverlay/src/mesh/outline/uniq_iter.rs +++ b/iOverlay/src/mesh/uniq_iter.rs @@ -106,7 +106,7 @@ fn include_point(p0: IntPoint, p1: IntPoint, p2: IntPoint } #[cfg(test)] mod tests { - use crate::mesh::outline::uniq_iter::{UniqueSegment, UniqueSegmentsIter}; + use crate::mesh::uniq_iter::{UniqueSegment, UniqueSegmentsIter}; use alloc::vec::Vec; use i_float::int::point::IntPoint; use i_shape::int_path; diff --git a/iOverlay/tests/critical_hypotheses_tests.rs b/iOverlay/tests/critical_hypotheses_tests.rs index c5b3ecbc..c20b228e 100644 --- a/iOverlay/tests/critical_hypotheses_tests.rs +++ b/iOverlay/tests/critical_hypotheses_tests.rs @@ -6,8 +6,8 @@ use i_overlay::core::overlay::ShapeType; use i_overlay::core::overlay_rule::OverlayRule; use i_overlay::core::relate::PredicateOverlay; use i_overlay::core::solver::Solver; -use i_overlay::mesh::stroke::offset::StrokeOffset; -use i_overlay::mesh::style::StrokeStyle; +use i_overlay::mesh::float::stroke::offset::StrokeOffset; +use i_overlay::mesh::float::style::StrokeStyle; fn rectangle(x0: i32, y0: i32, x1: i32, y1: i32) -> Vec { vec![ diff --git a/iOverlay/tests/float_point_adapter.rs b/iOverlay/tests/float_point_adapter.rs index 3580ba82..58170a28 100644 --- a/iOverlay/tests/float_point_adapter.rs +++ b/iOverlay/tests/float_point_adapter.rs @@ -6,7 +6,7 @@ mod tests { use i_overlay::core::overlay::ShapeType; use i_overlay::core::overlay_rule::OverlayRule; use i_overlay::float::overlay::FloatOverlay; - use i_shape::source::resource::ShapeResource; + use i_shape::source::float::resource::ShapeResource; #[test] fn test_adapter_with_rect() { diff --git a/iOverlay/tests/int_outline_bounds_tests.rs b/iOverlay/tests/int_outline_bounds_tests.rs new file mode 100644 index 00000000..41a6521c --- /dev/null +++ b/iOverlay/tests/int_outline_bounds_tests.rs @@ -0,0 +1,133 @@ +use core::cell::Cell; +use i_float::int::point::IntPoint; +use i_float::int::rect::IntRect; +use i_overlay::mesh::int::outline::offset::{IntOutlineError, IntOutlineOffset}; +use i_overlay::mesh::int::style::{IntLineJoin, IntOutlineStyle}; +use i_shape::source::int::resource::IntShapeResource; + +macro_rules! check_bounds { + ($name:ident, $int:ty) => { + #[test] + fn $name() { + let limit: $int = 1 << (<$int>::BITS - 2); + let radius: $int = 1024; + let lo = -limit + radius + 1; + let hi = limit - radius - 1; + let source = |rect: IntRect<$int>| { + [ + IntPoint::new(rect.min_x, rect.min_y), + IntPoint::new(rect.max_x, rect.min_y), + IntPoint::new(rect.max_x, rect.max_y), + IntPoint::new(rect.min_x, rect.max_y), + ] + }; + let valid = source(IntRect::new(lo, hi, lo, hi)); + for style in [ + IntOutlineStyle::new(radius), + IntOutlineStyle::new(-radius), + IntOutlineStyle::new(0).outer_offset(radius), + IntOutlineStyle::new(0).inner_offset(-radius), + ] { + assert_eq!(valid.validate_outline(&style), Ok(())); + for rect in [ + IntRect::new(lo - 1, hi, lo, hi), + IntRect::new(lo, hi + 1, lo, hi), + IntRect::new(lo, hi, lo - 1, hi), + IntRect::new(lo, hi, lo, hi + 1), + ] { + assert_eq!( + source(rect).validate_outline(&style), + Err(IntOutlineError::CoordinateOutOfRange) + ); + } + } + // Full storage-range inputs and offsets must reject without wrapping, + // including the magnitude of MIN, which cannot be represented in I. + for coordinate in [<$int>::MIN, -limit, limit, <$int>::MAX] { + let path = [IntPoint::new(coordinate, 0)]; + assert_eq!( + path.validate_outline(&IntOutlineStyle::new(0)), + Err(IntOutlineError::CoordinateOutOfRange) + ); + assert_eq!( + path.validate_outline(&IntOutlineStyle::new(<$int>::MIN)), + Err(IntOutlineError::CoordinateOutOfRange) + ); + } + for offset in [<$int>::MIN, <$int>::MAX] { + assert_eq!( + [IntPoint::<$int>::ZERO].validate_outline(&IntOutlineStyle::new(offset)), + Err(IntOutlineError::CoordinateOutOfRange) + ); + } + } + }; +} + +check_bounds!(expanded_bounds_i16, i16); +check_bounds!(expanded_bounds_i32, i32); +check_bounds!(expanded_bounds_i64, i64); + +#[test] +fn empty_input_needs_no_coordinate_space_but_still_checks_join_support() { + let empty: Vec = vec![]; + assert_eq!(empty.validate_outline(&IntOutlineStyle::new(i32::MIN)), Ok(())); + for join in [IntLineJoin::Miter, IntLineJoin::Round] { + assert_eq!( + empty.validate_outline(&IntOutlineStyle::new(0).line_join(join)), + Err(IntOutlineError::UnsupportedJoin(join)) + ); + } +} + +struct CountedResource<'p> { + path: &'p [IntPoint], + traversals: Cell, +} + +impl IntShapeResource for CountedResource<'_> { + type ResourceIter<'a> + = core::iter::Once<&'a [IntPoint]> + where + Self: 'a; + + fn iter_paths(&self) -> Self::ResourceIter<'_> { + self.traversals.set(self.traversals.get() + 1); + core::iter::once(self.path) + } +} + +#[test] +fn release_construction_does_not_repeat_the_bounds_pass() { + let path = [ + IntPoint::new(0, 0), + IntPoint::new(8192, 0), + IntPoint::new(8192, 8192), + IntPoint::new(0, 8192), + ]; + let source = CountedResource { + path: &path, + traversals: Cell::new(0), + }; + let style = IntOutlineStyle::new(1024); + assert_eq!(source.validate_outline(&style), Ok(())); + assert_eq!(source.traversals.get(), 1); + source.traversals.set(0); + assert_eq!(source.outline(&style).unwrap().len(), 1); + assert_eq!( + source.traversals.get(), + if cfg!(debug_assertions) { 2 } else { 1 } + ); +} + +#[cfg(debug_assertions)] +#[test] +#[should_panic(expected = "outline bounds exceed the safe coordinate range")] +fn debug_construction_asserts_the_bounds_precondition() { + let path = [ + IntPoint::new(0, 0), + IntPoint::new(i32::MAX, 0), + IntPoint::new(0, 8192), + ]; + let _ = path.outline(&IntOutlineStyle::new(0)); +} diff --git a/iOverlay/tests/int_outline_tests.rs b/iOverlay/tests/int_outline_tests.rs new file mode 100644 index 00000000..cc816028 --- /dev/null +++ b/iOverlay/tests/int_outline_tests.rs @@ -0,0 +1,283 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::overlay::{ContourDirection, IntOverlayOptions}; +use i_overlay::mesh::int::outline::offset::{IntOutlineError, IntOutlineOffset}; +use i_overlay::mesh::int::style::{IntLineJoin, IntOutlineStyle}; +use i_shape::flat::buffer::{FlatContoursBuffer, FlatShapesBuffer}; +use i_shape::int::area::Area; +use i_shape::int::shape::IntShapes; + +fn rectangle(x0: i32, y0: i32, x1: i32, y1: i32) -> Vec { + vec![ + IntPoint::new(x0, y0), + IntPoint::new(x1, y0), + IntPoint::new(x1, y1), + IntPoint::new(x0, y1), + ] +} + +macro_rules! check_engine { + ($name:ident, $int:ty) => { + #[test] + fn $name() { + let path = [ + IntPoint::new(0 as $int, 0), + IntPoint::new(8192, 0), + IntPoint::new(8192, 8192), + IntPoint::new(0, 8192), + ]; + for offset in [-1024, 0, 1024] { + let result = path.outline(&IntOutlineStyle::new(offset)).unwrap(); + assert_eq!(result.len(), 1); + let expected: i64 = if offset > 0 { + 8192 * 8192 + 4 * 8192 * 1024 + 2 * 1024 * 1024 + } else if offset < 0 { + 6144 * 6144 + } else { + 8192 * 8192 + }; + assert_eq!(result.area() as i64, expected); + assert_eq!(result[0][0].len(), if offset > 0 { 8 } else { 4 }); + } + assert!( + path.outline(&IntOutlineStyle::new(-4096)) + .unwrap() + .is_empty() + ); + } + }; +} + +check_engine!(bevel_i16, i16); +check_engine!(bevel_i32, i32); +check_engine!(bevel_i64, i64); + +#[test] +fn offsets_in_the_expected_i32_width_range() { + for radius in [1 << 10, 1 << 12, 1 << 16] { + let side = radius * 16; + let result = rectangle(0, 0, side, side) + .outline(&IntOutlineStyle::new(radius)) + .unwrap(); + let (s, r) = (i64::from(side), i64::from(radius)); + assert_eq!(result.area(), s * s + 4 * s * r + 2 * r * r); + } +} + +#[test] +fn holes_use_their_own_offset_and_collapse_without_affecting_other_holes() { + let outer = rectangle(0, 0, 32768, 32768); + let mut hole = rectangle(8192, 8192, 16384, 16384); + hole.reverse(); + let mut small_hole = rectangle(2048, 2048, 4096, 4096); + small_hole.reverse(); + let style = IntOutlineStyle::new(2048).inner_offset(1024); + let expected = vec![outer.clone(), hole.clone()].outline(&style).unwrap(); + assert_eq!(expected[0].len(), 2); + assert_eq!( + expected.area(), + 32768_i64.pow(2) + 4 * 32768 * 2048 + 2 * 2048_i64.pow(2) - 6144_i64.pow(2) + ); + for source in [ + vec![outer.clone(), hole.clone(), small_hole.clone()], + vec![small_hole, outer, hole], + ] { + assert_eq!(source.outline(&style).unwrap(), expected); + } +} + +#[test] +fn area_filter_runs_after_union() { + // Each overlapping input rectangle has area 8*1024²; their union is 12*1024². + let source = [rectangle(0, 0, 4096, 2048), rectangle(2048, 0, 6144, 2048)]; + let style = IntOutlineStyle::new(0); + let expected = source.outline(&style).unwrap(); + assert_eq!(expected.area(), 12 * 1024 * 1024); + let mut options = IntOverlayOptions::default(); + options.min_output_area = 10 * 1024 * 1024; + assert_eq!(source.outline_custom(&style, options).unwrap(), expected); + options.min_output_area = 13 * 1024 * 1024; + assert!(source.outline_custom(&style, options).unwrap().is_empty()); +} + +#[test] +fn resource_forms_and_flat_output_agree() { + let path = rectangle(0, 0, 8192, 8192); + let shape = vec![path.clone()]; + let shapes = vec![shape.clone()]; + let mut contours = FlatContoursBuffer::default(); + contours.set_with_shapes(&shapes); + let flat_shapes = FlatShapesBuffer { + points: path.clone(), + contour_ranges: vec![0..4], + shape_ranges: vec![0..1], + }; + let style = IntOutlineStyle::new(1024); + let expected = path.outline(&style).unwrap(); + assert_eq!(path.as_slice().outline(&style).unwrap(), expected); + let borrowed_paths = [path.as_slice()]; + assert_eq!(borrowed_paths.outline(&style).unwrap(), expected); + assert_eq!(shape.outline(&style).unwrap(), expected); + assert_eq!(shapes.outline(&style).unwrap(), expected); + assert_eq!(contours.outline(&style).unwrap(), expected); + assert_eq!(flat_shapes.outline(&style).unwrap(), expected); + let mut output = FlatContoursBuffer::default(); + shapes.outline_into(&style, &mut output).unwrap(); + let mut expected_buffer = FlatContoursBuffer::default(); + expected_buffer.set_with_shapes(&expected); + assert_eq!(output, expected_buffer); + // Empty success must replace the previous output. + let empty: Vec = vec![]; + empty.outline_into(&style, &mut output).unwrap(); + assert!(output.points.is_empty() && output.ranges.is_empty()); +} + +#[test] +fn zero_offset_cleans_repeated_points_and_preserves_holes() { + let mut outer = rectangle(0, 0, 8192, 8192); + outer.insert(1, outer[0]); + outer.push(outer[0]); + let mut hole = rectangle(2048, 2048, 4096, 4096); + hole.reverse(); + let source = vec![outer, hole]; + let result = source.outline(&IntOutlineStyle::new(0)).unwrap(); + assert_eq!(result[0].len(), 2); + assert_eq!(result.area(), 8192_i64.pow(2) - 2048_i64.pow(2)); + assert!(result[0].iter().all(|path| path.len() == 4)); + let empty = vec![ + vec![], + vec![IntPoint::new(0, 0); 4], + vec![IntPoint::new(0, 0), IntPoint::new(1, 1), IntPoint::new(2, 2)], + ]; + assert!(empty.outline(&IntOutlineStyle::new(1024)).unwrap().is_empty()); +} + +#[test] +fn output_direction_is_configurable() { + let path = rectangle(0, 0, 8192, 8192); + let options = IntOverlayOptions { + output_direction: ContourDirection::Clockwise, + ..Default::default() + }; + let result = path.outline_custom(&IntOutlineStyle::new(1024), options).unwrap(); + assert!(result.area() < 0); +} + +#[test] +fn errors_preserve_output_and_stubs_are_explicit() { + let path = rectangle(0, 0, 8192, 8192); + let mut output = FlatContoursBuffer::default(); + output.set_with_contour(&path); + let saved = output.clone(); + for join in [IntLineJoin::Miter, IntLineJoin::Round] { + let result = path.outline_into(&IntOutlineStyle::new(1024).line_join(join), &mut output); + assert_eq!(result, Err(IntOutlineError::UnsupportedJoin(join))); + assert_eq!(output, saved); + } +} + +#[test] +fn validation_rejects_out_of_range_operations_without_building() { + for (path, offset) in [ + (rectangle(0, 0, i32::MAX, 8192), 0), + (rectangle((1 << 30) - 8192, 0, (1 << 30) - 1, 8192), 1024), + (rectangle(0, 0, 8192, 8192), i32::MIN), + ] { + assert_eq!( + path.validate_outline(&IntOutlineStyle::new(offset)), + Err(IntOutlineError::CoordinateOutOfRange) + ); + } +} + +#[test] +fn i64_translation_above_float_precision_preserves_geometry() { + let path = [ + IntPoint::new(0_i64, 0), + IntPoint::new(8192, 0), + IntPoint::new(8192, 8192), + IntPoint::new(0, 8192), + ]; + let style = IntOutlineStyle::new(1024); + let expected = path.outline(&style).unwrap(); + let shift = (1_i64 << 60) + 17; + let translated = path.map(|p| IntPoint::new(p.x + shift, p.y - shift)); + let mut actual = translated.outline(&style).unwrap(); + for p in actual.iter_mut().flatten().flatten() { + p.x -= shift; + p.y += shift; + } + assert_eq!(actual, expected); +} + +fn canonical(mut shapes: IntShapes) -> IntShapes { + for shape in &mut shapes { + for path in shape.iter_mut() { + let first = path.iter().enumerate().min_by_key(|(_, p)| **p).unwrap().0; + path.rotate_left(first); + } + shape[1..].sort(); + } + shapes.sort(); + shapes +} + +#[test] +fn diagonal_bevel_uses_scaled_perpendiculars() { + // Every edge is a 3-4-5 triangle: the outward displacement is (±3072, ±4096). + let path = [ + IntPoint::new(0, -12288), + IntPoint::new(16384, 0), + IntPoint::new(0, 12288), + IntPoint::new(-16384, 0), + ]; + let expected = vec![vec![vec![ + IntPoint::new(3072, -16384), + IntPoint::new(19456, -4096), + IntPoint::new(19456, 4096), + IntPoint::new(3072, 16384), + IntPoint::new(-3072, 16384), + IntPoint::new(-19456, 4096), + IntPoint::new(-19456, -4096), + IntPoint::new(-3072, -16384), + ]]]; + let result = path.outline(&IntOutlineStyle::new(5120)).unwrap(); + assert_eq!(canonical(result), canonical(expected)); +} + +#[test] +fn bevel_matches_existing_float_pipeline_on_exact_axis_normals() { + use i_overlay::float::overlay::OverlayOptions; + use i_overlay::mesh::float::outline::offset::OutlineOffset; + use i_overlay::mesh::float::style::OutlineStyle; + let path = vec![ + IntPoint::new(0, 0), + IntPoint::new(8192, 0), + IntPoint::new(8192, 4096), + IntPoint::new(4096, 4096), + IntPoint::new(4096, 8192), + IntPoint::new(0, 8192), + ]; + let float: Vec<_> = path.iter().map(|p| [p.x as f64, p.y as f64]).collect(); + for offset in [-1024, 0, 1024] { + let actual = path.outline(&IntOutlineStyle::new(offset)).unwrap(); + let mut options = OverlayOptions::default(); + options.clean_result = false; + let expected = float + .outline_custom_fixed_scale(&OutlineStyle::new(offset as f64), options, 1.0) + .unwrap(); + let expected: IntShapes = expected + .iter() + .map(|shape| { + shape + .iter() + .map(|path| { + path.iter() + .map(|p| IntPoint::new(p[0].round() as i32, p[1].round() as i32)) + .collect() + }) + .collect() + }) + .collect(); + assert_eq!(canonical(actual), canonical(expected)); + } +} diff --git a/iOverlay/tests/mesh_scale_tests.rs b/iOverlay/tests/mesh_scale_tests.rs index 97317a05..75b9acf6 100644 --- a/iOverlay/tests/mesh_scale_tests.rs +++ b/iOverlay/tests/mesh_scale_tests.rs @@ -1,9 +1,9 @@ use i_overlay::core::fill_rule::FillRule; use i_overlay::core::overlay_rule::OverlayRule; use i_overlay::float::overlay::FloatOverlay; -use i_overlay::mesh::outline::offset::OutlineOffset; -use i_overlay::mesh::stroke::offset::StrokeOffset; -use i_overlay::mesh::style::{LineJoin, OutlineStyle, StrokeStyle}; +use i_overlay::mesh::float::outline::offset::OutlineOffset; +use i_overlay::mesh::float::stroke::offset::StrokeOffset; +use i_overlay::mesh::float::style::{LineJoin, OutlineStyle, StrokeStyle}; fn normalized_area(shapes: &[Vec>], scale: f64) -> f64 { shapes diff --git a/iOverlay/tests/outline_boundary_tests.rs b/iOverlay/tests/outline_boundary_tests.rs index 9c021f56..f3e048b6 100644 --- a/iOverlay/tests/outline_boundary_tests.rs +++ b/iOverlay/tests/outline_boundary_tests.rs @@ -1,5 +1,5 @@ -use i_overlay::mesh::outline::offset::OutlineOffset; -use i_overlay::mesh::style::{LineJoin, OutlineStyle}; +use i_overlay::mesh::float::outline::offset::OutlineOffset; +use i_overlay::mesh::float::style::{LineJoin, OutlineStyle}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; diff --git a/iOverlay/tests/outline_cell_oracle_tests.rs b/iOverlay/tests/outline_cell_oracle_tests.rs index 51e24873..16e68ab6 100644 --- a/iOverlay/tests/outline_cell_oracle_tests.rs +++ b/iOverlay/tests/outline_cell_oracle_tests.rs @@ -2,8 +2,8 @@ use i_float::int::point::IntPoint; use i_overlay::core::fill_rule::FillRule; use i_overlay::core::overlay::Overlay; use i_overlay::core::overlay_rule::OverlayRule; -use i_overlay::mesh::outline::offset::OutlineOffset; -use i_overlay::mesh::style::{LineJoin, OutlineStyle}; +use i_overlay::mesh::float::outline::offset::OutlineOffset; +use i_overlay::mesh::float::style::{LineJoin, OutlineStyle}; fn contains(shapes: &[Vec>], x: f64, y: f64) -> bool { shapes.iter().any(|shape| { diff --git a/iOverlay/tests/outline_grid_area_tests.rs b/iOverlay/tests/outline_grid_area_tests.rs index 3f1e0c8f..c61bd47f 100644 --- a/iOverlay/tests/outline_grid_area_tests.rs +++ b/iOverlay/tests/outline_grid_area_tests.rs @@ -2,8 +2,8 @@ use i_float::int::point::IntPoint; use i_overlay::core::fill_rule::FillRule; use i_overlay::core::overlay::Overlay; use i_overlay::core::overlay_rule::OverlayRule; -use i_overlay::mesh::outline::offset::OutlineOffset; -use i_overlay::mesh::style::OutlineStyle; +use i_overlay::mesh::float::outline::offset::OutlineOffset; +use i_overlay::mesh::float::style::OutlineStyle; fn area(shapes: &[Vec>]) -> f64 { shapes diff --git a/iOverlay/tests/outline_identity_tests.rs b/iOverlay/tests/outline_identity_tests.rs index 8523ca7f..f56b33c3 100644 --- a/iOverlay/tests/outline_identity_tests.rs +++ b/iOverlay/tests/outline_identity_tests.rs @@ -3,8 +3,8 @@ use i_overlay::core::fill_rule::FillRule; use i_overlay::core::overlay::{ContourDirection, Overlay}; use i_overlay::core::overlay_rule::OverlayRule; use i_overlay::float::overlay::OverlayOptions; -use i_overlay::mesh::outline::offset::OutlineOffset; -use i_overlay::mesh::style::OutlineStyle; +use i_overlay::mesh::float::outline::offset::OutlineOffset; +use i_overlay::mesh::float::style::OutlineStyle; type Shapes = Vec>>; diff --git a/iOverlay/tests/outline_pipeline_tests.rs b/iOverlay/tests/outline_pipeline_tests.rs index 2a3e6fa3..09f9cf0a 100644 --- a/iOverlay/tests/outline_pipeline_tests.rs +++ b/iOverlay/tests/outline_pipeline_tests.rs @@ -1,7 +1,7 @@ use i_overlay::float::overlay::OverlayOptions; -use i_overlay::mesh::outline::offset::OutlineOffset; -use i_overlay::mesh::stroke::offset::StrokeOffset; -use i_overlay::mesh::style::{LineJoin, OutlineStyle, StrokeStyle}; +use i_overlay::mesh::float::outline::offset::OutlineOffset; +use i_overlay::mesh::float::stroke::offset::StrokeOffset; +use i_overlay::mesh::float::style::{LineJoin, OutlineStyle, StrokeStyle}; use i_shape::flat::float::FloatFlatContoursBuffer; fn rectangle(x0: f64, y0: f64, x1: f64, y1: f64) -> Vec<[f64; 2]> { diff --git a/iOverlay/tests/stroke_cap_precision_tests.rs b/iOverlay/tests/stroke_cap_precision_tests.rs index 75767de1..a7bf2d41 100644 --- a/iOverlay/tests/stroke_cap_precision_tests.rs +++ b/iOverlay/tests/stroke_cap_precision_tests.rs @@ -1,6 +1,6 @@ use i_overlay::float::overlay::OverlayOptions; -use i_overlay::mesh::stroke::offset::StrokeOffset; -use i_overlay::mesh::style::{LineCap, StrokeStyle}; +use i_overlay::mesh::float::stroke::offset::StrokeOffset; +use i_overlay::mesh::float::style::{LineCap, StrokeStyle}; #[test] fn round_caps_survive_coarse_integer_grid() { diff --git a/iOverlay/tests/stroke_coverage_tests.rs b/iOverlay/tests/stroke_coverage_tests.rs index f3fc05b1..2f613225 100644 --- a/iOverlay/tests/stroke_coverage_tests.rs +++ b/iOverlay/tests/stroke_coverage_tests.rs @@ -1,9 +1,9 @@ -use i_overlay::mesh::outline::offset::OutlineOffset; -use i_overlay::mesh::stroke::offset::StrokeOffset; -use i_overlay::mesh::style::OutlineStyle; -use i_overlay::mesh::style::{LineCap, LineJoin, StrokeStyle}; -use i_overlay::mesh::variable_stroke::offset::VariableStrokeOffset; -use i_overlay::mesh::variable_stroke::{StrokeVertex, VariableStrokeStyle}; +use i_overlay::mesh::float::outline::offset::OutlineOffset; +use i_overlay::mesh::float::stroke::offset::StrokeOffset; +use i_overlay::mesh::float::style::OutlineStyle; +use i_overlay::mesh::float::style::{LineCap, LineJoin, StrokeStyle}; +use i_overlay::mesh::float::variable_stroke::offset::VariableStrokeOffset; +use i_overlay::mesh::float::variable_stroke::{StrokeVertex, VariableStrokeStyle}; fn contains(shapes: &[Vec>], p: [f64; 2]) -> bool { shapes.iter().any(|shape| { diff --git a/iOverlay/tests/stroke_direction_tests.rs b/iOverlay/tests/stroke_direction_tests.rs index 81930d7d..9ae085c9 100644 --- a/iOverlay/tests/stroke_direction_tests.rs +++ b/iOverlay/tests/stroke_direction_tests.rs @@ -1,5 +1,5 @@ -use i_overlay::mesh::stroke::offset::StrokeOffset; -use i_overlay::mesh::style::{LineJoin, StrokeStyle}; +use i_overlay::mesh::float::stroke::offset::StrokeOffset; +use i_overlay::mesh::float::style::{LineJoin, StrokeStyle}; fn canonical(shapes: Vec>>) -> Vec>> { let mut shapes: Vec>> = shapes diff --git a/iOverlay/tests/variable_stroke_stress.rs b/iOverlay/tests/variable_stroke_stress.rs index 2784923a..1859bec9 100644 --- a/iOverlay/tests/variable_stroke_stress.rs +++ b/iOverlay/tests/variable_stroke_stress.rs @@ -1,5 +1,5 @@ -use i_overlay::mesh::variable_stroke::offset::VariableStrokeOffset; -use i_overlay::mesh::variable_stroke::{StrokeVertex, VariableStrokeStyle}; +use i_overlay::mesh::float::variable_stroke::offset::VariableStrokeOffset; +use i_overlay::mesh::float::variable_stroke::{StrokeVertex, VariableStrokeStyle}; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::time::{Duration, Instant}; From cd8a276e3b88a97d031ea371d88f65c086a1b3e3 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Sun, 13 Sep 2026 17:30:15 +0300 Subject: [PATCH 18/44] move to cordic api --- iOverlay/src/mesh/int/arc.rs | 255 ++++++++++++++++++++++++++++++++ iOverlay/src/mesh/int/mod.rs | 1 + iOverlay/tests/int_arc_tests.rs | 251 +++++++++++++++++++++++++++++++ 3 files changed, 507 insertions(+) create mode 100644 iOverlay/src/mesh/int/arc.rs create mode 100644 iOverlay/tests/int_arc_tests.rs diff --git a/iOverlay/src/mesh/int/arc.rs b/iOverlay/src/mesh/int/arc.rs new file mode 100644 index 00000000..173e20cd --- /dev/null +++ b/iOverlay/src/mesh/int/arc.rs @@ -0,0 +1,255 @@ +//! Directed unit-circle arcs using cached integer rotation matrices. + +use alloc::vec::Vec; +use i_float::int::angle::{Angle, Rotation}; +use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; +use i_float::int::unit_vector::UnitIntVector; + +/// Direction of traversal in Cartesian coordinates (y increases upward). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArcDirection { + Clockwise, + Counterclockwise, +} + +/// Settings shared by all arcs built with an [`ArcBuilder`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ArcOptions { + /// Maximum angular gap, including the final gap to the original endpoint. + /// Clamped to [`Self::MIN_STEP`]..=[`Self::MAX_STEP`] on construction. + pub max_step: Angle, + /// Relative matrix-angle accuracy exponent: 4 allows 1/16 error, 5 allows + /// 1/32, and 6 allows 1/64. Clamped to 4..=32; 32 selects full precision. + /// This changes matrix construction cost, not the coefficient format. + pub rotation_precision: u32, +} + +impl ArcOptions { + /// 1/1024 turn, or 0.3515625 degrees. + pub const MIN_STEP: Angle = Angle::from_bits(1 << 22); + /// 1/8 turn, or 45 degrees. + pub const MAX_STEP: Angle = Angle::from_bits(1 << 29); +} + +impl Default for ArcOptions { + fn default() -> Self { + Self { + max_step: Self::MAX_STEP, + rotation_precision: 5, + } + } +} + +/// Reusable storage and two cached rotation matrices for directed arcs. +/// +/// Output contains intermediate directions in traversal order, excluding both +/// input endpoints. The caller applies the center and radius and retains the +/// original contact points to avoid rounding seams. Equal rays describe an +/// empty arc; full turns are not supported. Opposite rays describe a semicircle +/// whose side is selected by `ArcDirection`. Major arcs are supported too. +/// +/// Matrices are constructed once, with [`Rotation::with_precision`]. Each build +/// measures the sweep with [`Angle::between`] and applies the appropriate matrix +/// repeatedly, without per-point CORDIC, square roots, or normalization. Steps +/// use the achieved matrix angle; the last gap can be shorter than the others. +/// Reversing a build need not produce the same intermediate directions. +/// +/// Inputs should be freshly normalized directions: length at least 0.99 for +/// `i32`/`i64`, or 0.95 for `i16`. The step error budget includes rounding during +/// at most 1536 applications. `i16` also checks the endpoint geometrically; +/// its coarse storage can noticeably contract long fine arcs. For those arcs, +/// prefer `i32` or `i64`. Output length never exceeds the starting length. +/// +/// ``` +/// use i_float::int::vector::IntVector; +/// use i_overlay::mesh::int::arc::{ArcBuilder, ArcDirection, ArcOptions}; +/// +/// let from = IntVector::::new(1, 0).fast_normalize().unwrap(); +/// let to = IntVector::::new(0, 1).fast_normalize().unwrap(); +/// let mut builder = ArcBuilder::new(ArcOptions::default()); +/// let directions = builder.build(from, to, ArcDirection::Counterclockwise); +/// assert!(!directions.is_empty()); +/// let offset = directions[0].scale(1024); +/// assert!(offset.x > 0 && offset.y > 0); +/// ``` +pub struct ArcBuilder { + options: ArcOptions, + short_arc_dot: I::Wide, + clockwise: ArcRotation, + counterclockwise: ArcRotation, + directions: Vec>, +} + +struct ArcRotation { + matrix: Rotation, + upper_step: u64, +} + +impl ArcBuilder { + const MAX_ROTATIONS: u64 = 1536; + // i32/i64: two metadata units plus component rounding at length >= 0.99. + // i16: metadata plus component rounding, even after contraction to 0.68. + const STEP_ERROR: u64 = if I::BITS == 16 { 1 << 18 } else { 4 }; + + /// Prepares both traversal directions. The result buffer starts empty. + pub fn new(options: ArcOptions) -> Self { + let options = ArcOptions { + max_step: Angle::from_bits( + options + .max_step + .bits() + .clamp(ArcOptions::MIN_STEP.bits(), ArcOptions::MAX_STEP.bits()), + ), + rotation_precision: options.rotation_precision.clamp(4, 32), + }; + // Counting by the upper step prevents endpoint overshoot. Budget the + // accumulated difference from the lower step in the final gap too. + // i16 stops geometrically, so it needs only a single-step reserve. + let reserve = if I::BITS == 16 { + 2 * Self::STEP_ERROR + } else { + 2 * Self::STEP_ERROR * (Self::MAX_ROTATIONS + 1) + 2 * Angle::MAX_ERROR as u64 + }; + let divisor = 1u64 << options.rotation_precision; + let available = options.max_step.bits() as u64 - reserve; + let requested = (available * divisor / (divisor + 1)) as u32; + let prepare = |bits| { + let matrix = Rotation::::with_precision(Angle::from_bits(bits), options.rotation_precision); + let step = (matrix.angle().bits() as i32).unsigned_abs() as u64; + debug_assert!(step > Self::STEP_ERROR); + ArcRotation { + matrix, + upper_step: step + Self::STEP_ERROR, + } + }; + // A sufficient small-arc test: input norms are <=1, so an unnormalized + // dot above cos(max_step) also bounds the normalized dot from below. + // Add eight Q30 units to cover the full CORDIC coefficient error, then + // round upward when narrowing to the Q28 dot scale of i16. + let cosine = options.max_step.cos() as u32 + 8; + let dot_bits = 2 * (I::BITS - 2); + let short_arc_dot = if dot_bits >= 30 { + I::Wide::from_u32(cosine) << (dot_bits - 30) + } else { + I::Wide::from_u32(cosine.div_ceil(1 << (30 - dot_bits))) + }; + Self { + options, + short_arc_dot, + clockwise: prepare(requested.wrapping_neg()), + counterclockwise: prepare(requested), + directions: Vec::new(), + } + } + + /// Returns the effective settings, after clamping to the supported range. + pub fn options(&self) -> ArcOptions { + self.options + } + + /// Clears the previous result, retaining its capacity, and returns the + /// intermediate directions. Settings and cached matrices remain reusable. + pub fn build( + &mut self, + from: UnitIntVector, + to: UnitIntVector, + direction: ArcDirection, + ) -> &[UnitIntVector] { + self.directions.clear(); + // Most shallow joins need no intermediate points. Avoid vectoring for + // those arcs, while distinguishing the directed minor and major arcs. + if cross(from, to, direction) >= I::Wide::ZERO && dot(from, to) >= self.short_arc_dot { + return &self.directions; + } + let (sweep, rotation) = match direction { + ArcDirection::Clockwise => (Angle::between(to, from), &self.clockwise), + ArcDirection::Counterclockwise => (Angle::between(from, to), &self.counterclockwise), + }; + if sweep.bits() == 0 + || sweep.bits() as u64 + Angle::MAX_ERROR as u64 <= self.options.max_step.bits() as u64 + { + return &self.directions; + } + + if I::BITS == 16 { + // Low precision storage accumulates too much error for a fixed + // count. Stop just before the next rotated direction reaches the + // endpoint. Each step is <90 degrees, so cross/dot suffice. + let mut current = from; + for _ in 0..Self::MAX_ROTATIONS { + let next = rotation.matrix.apply(current); + let near_end = + cross(current, to, direction) >= I::Wide::ZERO && dot(current, to) >= I::Wide::ZERO; + if cross(current, next, direction) <= I::Wide::ZERO + || (near_end && cross(next, to, direction) <= I::Wide::ZERO) + { + return &self.directions; + } + self.directions.push(next); + current = next; + } + debug_assert!(false, "freshly normalized directions must reach the arc endpoint"); + } else { + // Strictly below the lower bound on the sweep: both input endpoints + // are excluded even when the arc is an exact multiple of the step. + let lower = sweep.bits().saturating_sub(Angle::MAX_ERROR) as u64; + let count = lower.saturating_sub(1) / rotation.upper_step; + debug_assert!(count < Self::MAX_ROTATIONS); + self.directions.reserve(count as usize); + let mut current = from; + for _ in 0..count { + current = rotation.matrix.apply(current); + self.directions.push(current); + } + } + &self.directions + } +} + +impl Default for ArcBuilder { + fn default() -> Self { + Self::new(ArcOptions::default()) + } +} + +fn cross(a: UnitIntVector, b: UnitIntVector, direction: ArcDirection) -> I::Wide { + let cross = a.x().to_wide() * b.y().to_wide() - a.y().to_wide() * b.x().to_wide(); + match direction { + ArcDirection::Counterclockwise => cross, + ArcDirection::Clockwise => -cross, + } +} + +fn dot(a: UnitIntVector, b: UnitIntVector) -> I::Wide { + a.x().to_wide() * b.x().to_wide() + a.y().to_wide() * b.y().to_wide() +} + +#[cfg(test)] +mod tests { + use super::{ArcBuilder, ArcDirection, ArcOptions}; + use i_float::int::vector::IntVector; + + #[test] + fn repeated_builds_reuse_buffer_and_rotations() { + let from = IntVector::::new(1, 0).fast_normalize().unwrap(); + let to = IntVector::::new(0, -1).fast_normalize().unwrap(); + let mut builder = ArcBuilder::new(ArcOptions { + max_step: ArcOptions::MIN_STEP, + ..ArcOptions::default() + }); + builder.build(from, to, ArcDirection::Counterclockwise); + let output_ptr = builder.directions.as_ptr(); + let output_capacity = builder.directions.capacity(); + let cw = builder.clockwise.matrix.apply(from); + let ccw = builder.counterclockwise.matrix.apply(from); + assert!(output_capacity > 0); + for _ in 0..3 { + assert!(builder.build(from, from, ArcDirection::Clockwise).is_empty()); + builder.build(from, to, ArcDirection::Counterclockwise); + assert_eq!(builder.directions.as_ptr(), output_ptr); + assert_eq!(builder.directions.capacity(), output_capacity); + assert_eq!(builder.clockwise.matrix.apply(from), cw); + assert_eq!(builder.counterclockwise.matrix.apply(from), ccw); + } + } +} diff --git a/iOverlay/src/mesh/int/mod.rs b/iOverlay/src/mesh/int/mod.rs index a5fea8c6..82489cfc 100644 --- a/iOverlay/src/mesh/int/mod.rs +++ b/iOverlay/src/mesh/int/mod.rs @@ -1,3 +1,4 @@ //! Mesh operations on integer input geometry. +pub mod arc; pub mod outline; pub mod style; diff --git a/iOverlay/tests/int_arc_tests.rs b/iOverlay/tests/int_arc_tests.rs new file mode 100644 index 00000000..2719a7f5 --- /dev/null +++ b/iOverlay/tests/int_arc_tests.rs @@ -0,0 +1,251 @@ +use i_float::int::angle::Angle; +use i_float::int::number::int::IntNumber; +use i_float::int::number::wide_int::WideIntNumber; +use i_float::int::unit_vector::UnitIntVector; +use i_float::int::vector::IntVector; +use i_overlay::mesh::int::arc::{ArcBuilder, ArcDirection, ArcOptions}; +use std::f64::consts::TAU; + +fn unit(x: i32, y: i32) -> UnitIntVector { + let coordinate = |v: i32| { + let magnitude = I::Wide::from_u32(v.unsigned_abs()); + if v < 0 { -magnitude } else { magnitude } + }; + IntVector::::new(coordinate(x), coordinate(y)) + .fast_normalize() + .unwrap() +} + +fn angle(v: UnitIntVector) -> f64 { + v.y().to_f64().atan2(v.x().to_f64()) +} + +fn check_arc( + builder: &mut ArcBuilder, + from: UnitIntVector, + to: UnitIntVector, + direction: ArcDirection, +) { + let sign = if direction == ArcDirection::Counterclockwise { + 1.0 + } else { + -1.0 + }; + let expected = (sign * (angle(to) - angle(from))).rem_euclid(TAU); + let scale = UnitIntVector::::DENOMINATOR.to_f64(); + let max_angle = builder.options().max_step.bits() as f64 * TAU / 4294967296.0; + let result = builder.build(from, to, direction); + assert!(result.len() < 1536); + if expected == 0.0 { + assert!(result.is_empty()); + return; + } + + let mut previous = from; + let mut total = 0.0; + for current in result.iter().copied().chain(std::iter::once(to)) { + let delta = (sign * (angle(current) - angle(previous))).rem_euclid(TAU); + assert!(delta > 0.0, "duplicate direction"); + assert!( + delta <= max_angle + 1e-12, + "step {} exceeds {} for {}-bit vectors", + delta.to_degrees(), + max_angle.to_degrees(), + I::BITS, + ); + let x = current.x().to_f64() / scale; + let y = current.y().to_f64() / scale; + assert!((if I::BITS == 16 { 0.46 } else { 0.98 }..=1.000000000000001).contains(&(x * x + y * y))); + total += delta; + previous = current; + } + assert!((total - expected).abs() < 1e-10, "wrong directed arc"); +} + +fn check_type() { + // Axes, diagonals, uneven lengths, almost equal/opposite directions, + // and both sides of quadrant boundaries exercise minor and major arcs. + let pairs = [ + (1, 0), + (10000, 1), + (3, 4), + (1, 1), + (1, 10000), + (0, 1), + (-1, 10000), + (-4, 3), + (-1, 1), + (-1, 0), + (-10000, -1), + (-3, -4), + (-1, -1), + (-1, -10000), + (0, -1), + (1, -10000), + (4, -3), + (1, -1), + (10000, -1), + ]; + let directions: Vec<_> = pairs.into_iter().map(|(x, y)| unit::(x, y)).collect(); + for precision in [4, 5, 6, 32] { + for max_step in [ + ArcOptions::MAX_STEP, + ArcOptions::MIN_STEP, + Angle::from_bits(1 << 26), + ] { + let mut builder = ArcBuilder::::new(ArcOptions { + max_step, + rotation_precision: precision, + }); + for &from in &directions { + for &to in &directions { + for direction in [ArcDirection::Clockwise, ArcDirection::Counterclockwise] { + check_arc(&mut builder, from, to, direction); + } + } + } + } + } +} + +#[test] +fn arcs_i16() { + check_type::(); +} + +#[test] +fn arcs_i32() { + check_type::(); +} + +#[test] +fn arcs_i64() { + check_type::(); +} + +#[test] +fn settings_are_clamped_and_default_to_precision_five() { + let defaults = ArcBuilder::::default().options(); + assert_eq!(defaults.max_step, ArcOptions::MAX_STEP); + assert_eq!(defaults.rotation_precision, 5); + let low = ArcBuilder::::new(ArcOptions { + max_step: Angle::from_bits(0), + rotation_precision: 0, + }) + .options(); + assert_eq!(low.max_step, ArcOptions::MIN_STEP); + assert_eq!(low.rotation_precision, 4); + let high = ArcBuilder::::new(ArcOptions { + max_step: Angle::from_bits(u32::MAX), + rotation_precision: u32::MAX, + }) + .options(); + assert_eq!(high.max_step, ArcOptions::MAX_STEP); + assert_eq!(high.rotation_precision, 32); +} + +#[test] +fn opposite_directions_select_the_requested_semicircle() { + let mut builder = ArcBuilder::::default(); + let from = unit(1, 0); + let to = unit(-1, 0); + let upper = builder.build(from, to, ArcDirection::Counterclockwise); + assert!(!upper.is_empty()); + assert!(upper.iter().all(|v| v.y() > 0)); + let lower = builder.build(from, to, ArcDirection::Clockwise); + assert!(!lower.is_empty()); + assert!(lower.iter().all(|v| v.y() < 0)); +} + +#[test] +fn empty_and_short_arcs_clear_the_previous_result() { + let mut builder = ArcBuilder::::default(); + let from = unit(1, 0); + let turn = ArcDirection::Counterclockwise; + assert!(!builder.build(from, unit(-1, 0), turn).is_empty()); + assert!(builder.build(from, from, turn).is_empty()); + assert!(builder.build(from, unit(10, 1), turn).is_empty()); + // Collinear inputs may have unequal approximate lengths. + assert!(builder.build(unit(3, 4), unit(3000, 4000), turn).is_empty()); +} + +#[test] +fn gaps_around_integer_multiples_stay_within_limit() { + for precision in [4, 5, 6] { + for max_step in [ + ArcOptions::MIN_STEP, + Angle::from_bits(1 << 26), + ArcOptions::MAX_STEP, + ] { + let mut builder = ArcBuilder::::new(ArcOptions { + max_step, + rotation_precision: precision, + }); + let step = max_step.bits() as f64 * TAU / 4294967296.0; + for start in [0.0_f64, 0.137, 1.91, 4.21] { + for multiple in [1.0, 2.0, 3.0, 7.0, 31.0, 1000.0] { + for delta in [-1e-7, 0.0, 1e-7] { + let sweep = multiple * step + delta; + if sweep >= TAU { + continue; + } + let make = |a: f64| { + unit::((a.cos() * 1e8).round() as i32, (a.sin() * 1e8).round() as i32) + }; + for direction in [ArcDirection::Clockwise, ArcDirection::Counterclockwise] { + let sign = if direction == ArcDirection::Clockwise { + -1.0 + } else { + 1.0 + }; + check_arc(&mut builder, make(start), make(start + sign * sweep), direction); + } + } + } + } + } + } +} + +#[test] +fn long_arcs_accumulate_less_than_one_coordinate_unit_of_rotation_error() { + fn check() { + let scale = UnitIntVector::::DENOMINATOR.to_f64(); + for precision in [4, 5, 6] { + let mut builder = ArcBuilder::::new(ArcOptions { + max_step: ArcOptions::MIN_STEP, + rotation_precision: precision, + }); + for direction in [ArcDirection::Clockwise, ArcDirection::Counterclockwise] { + let from = unit::(3, 4); + let end = unit::(3001, 4000); + let to = if direction == ArcDirection::Clockwise { + unit::(3000, 4001) + } else { + end + }; + let points = builder.build(from, to, direction); + assert!(points.len() > 1000); + // Independently measure the first applied rotation, then compare + // every output with its ideal multiple. Includes contraction, + // coefficient error and application rounding, not initial normalization. + let step = angle(points[0]) - angle(from); + let length = (from.x().to_f64().hypot(from.y().to_f64())) / scale; + for (i, v) in points.iter().enumerate() { + let expected_angle = angle(from) + (i + 1) as f64 * step; + let dx = v.x().to_f64() / scale - length * expected_angle.cos(); + let dy = v.y().to_f64() / scale - length * expected_angle.sin(); + for radius in [1024.0, 4096.0, 65536.0] { + assert!( + dx.hypot(dy) * radius < 1.0, + "accumulated error exceeds one unit for {} bits", + I::BITS + ); + } + } + } + } + } + check::(); + check::(); +} From 859de7b1ed4fe9e9200b271ae334c4bc8e996b22 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Sun, 13 Sep 2026 22:24:04 +0300 Subject: [PATCH 19/44] migrate offset to int --- .../overlay_editor/src/app/outline/content.rs | 4 +- .../overlay_editor/src/app/stroke/content.rs | 4 +- .../src/app/variable_stroke/content.rs | 4 +- iOverlay/README.md | 25 +- iOverlay/src/mesh/float/math.rs | 19 - iOverlay/src/mesh/float/miter.rs | 67 - iOverlay/src/mesh/float/mod.rs | 3 - iOverlay/src/mesh/float/outline/builder.rs | 275 ----- .../src/mesh/float/outline/builder_join.rs | 251 ---- iOverlay/src/mesh/float/outline/mod.rs | 3 - iOverlay/src/mesh/float/outline/offset.rs | 131 +- iOverlay/src/mesh/float/outline/section.rs | 43 - iOverlay/src/mesh/float/rotator.rs | 84 -- iOverlay/src/mesh/float/stroke/builder.rs | 310 ----- iOverlay/src/mesh/float/stroke/builder_cap.rs | 178 --- .../src/mesh/float/stroke/builder_join.rs | 391 ------ iOverlay/src/mesh/float/stroke/mod.rs | 4 - iOverlay/src/mesh/float/stroke/offset.rs | 113 +- iOverlay/src/mesh/float/stroke/section.rs | 62 - iOverlay/src/mesh/float/style.rs | 90 ++ .../src/mesh/float/variable_stroke/builder.rs | 1084 ----------------- .../src/mesh/float/variable_stroke/debug.rs | 17 +- .../src/mesh/float/variable_stroke/mod.rs | 2 - .../src/mesh/float/variable_stroke/offset.rs | 100 +- .../src/mesh/float/variable_stroke/section.rs | 165 --- iOverlay/src/mesh/int/bounds.rs | 12 + iOverlay/src/mesh/int/join.rs | 125 ++ iOverlay/src/mesh/int/math.rs | 47 + iOverlay/src/mesh/int/mod.rs | 8 + iOverlay/src/mesh/int/outline/bounds.rs | 12 +- iOverlay/src/mesh/int/outline/build.rs | 40 +- iOverlay/src/mesh/int/outline/builder.rs | 4 +- iOverlay/src/mesh/int/outline/builder_join.rs | 37 +- iOverlay/src/mesh/int/outline/offset.rs | 16 +- iOverlay/src/mesh/int/outline/section.rs | 18 +- iOverlay/src/mesh/int/stroke/builder.rs | 290 +++++ iOverlay/src/mesh/int/stroke/mod.rs | 2 + iOverlay/src/mesh/int/stroke/offset.rs | 99 ++ iOverlay/src/mesh/int/style.rs | 59 +- .../src/mesh/int/variable_stroke/builder.rs | 581 +++++++++ .../src/mesh/int/variable_stroke/debug.rs | 37 + iOverlay/src/mesh/int/variable_stroke/mod.rs | 10 + .../src/mesh/int/variable_stroke/offset.rs | 120 ++ .../src/mesh/int/variable_stroke/resource.rs | 185 +++ .../src/mesh/int/variable_stroke/section.rs | 72 ++ .../src/mesh/int/variable_stroke/style.rs | 43 + .../src/mesh/int/variable_stroke/tests.rs | 264 ++++ iOverlay/tests/int_mesh_tests.rs | 285 +++++ iOverlay/tests/int_outline_bounds_tests.rs | 9 +- iOverlay/tests/int_outline_tests.rs | 13 +- 50 files changed, 2563 insertions(+), 3254 deletions(-) delete mode 100644 iOverlay/src/mesh/float/math.rs delete mode 100644 iOverlay/src/mesh/float/miter.rs delete mode 100644 iOverlay/src/mesh/float/outline/builder.rs delete mode 100644 iOverlay/src/mesh/float/outline/builder_join.rs delete mode 100644 iOverlay/src/mesh/float/outline/section.rs delete mode 100644 iOverlay/src/mesh/float/rotator.rs delete mode 100644 iOverlay/src/mesh/float/stroke/builder.rs delete mode 100644 iOverlay/src/mesh/float/stroke/builder_cap.rs delete mode 100644 iOverlay/src/mesh/float/stroke/builder_join.rs delete mode 100644 iOverlay/src/mesh/float/stroke/section.rs delete mode 100644 iOverlay/src/mesh/float/variable_stroke/builder.rs delete mode 100644 iOverlay/src/mesh/float/variable_stroke/section.rs create mode 100644 iOverlay/src/mesh/int/bounds.rs create mode 100644 iOverlay/src/mesh/int/join.rs create mode 100644 iOverlay/src/mesh/int/math.rs create mode 100644 iOverlay/src/mesh/int/stroke/builder.rs create mode 100644 iOverlay/src/mesh/int/stroke/mod.rs create mode 100644 iOverlay/src/mesh/int/stroke/offset.rs create mode 100644 iOverlay/src/mesh/int/variable_stroke/builder.rs create mode 100644 iOverlay/src/mesh/int/variable_stroke/debug.rs create mode 100644 iOverlay/src/mesh/int/variable_stroke/mod.rs create mode 100644 iOverlay/src/mesh/int/variable_stroke/offset.rs create mode 100644 iOverlay/src/mesh/int/variable_stroke/resource.rs create mode 100644 iOverlay/src/mesh/int/variable_stroke/section.rs create mode 100644 iOverlay/src/mesh/int/variable_stroke/style.rs create mode 100644 iOverlay/src/mesh/int/variable_stroke/tests.rs create mode 100644 iOverlay/tests/int_mesh_tests.rs diff --git a/examples/overlay_editor/src/app/outline/content.rs b/examples/overlay_editor/src/app/outline/content.rs index 3032493f..d1c4396b 100644 --- a/examples/overlay_editor/src/app/outline/content.rs +++ b/examples/overlay_editor/src/app/outline/content.rs @@ -8,8 +8,8 @@ use crate::point_editor::point::PathsToEditorPoints; use crate::point_editor::widget::PointEditUpdate; use i_triangle::i_overlay::i_float::int::point::IntPoint; use i_triangle::i_overlay::i_float::int::rect::IntRect; -use i_triangle::i_overlay::mesh::outline::offset::OutlineOffset; -use i_triangle::i_overlay::mesh::style::{LineJoin, OutlineStyle}; +use i_triangle::i_overlay::mesh::float::outline::offset::OutlineOffset; +use i_triangle::i_overlay::mesh::float::style::{LineJoin, OutlineStyle}; use iced::widget::{scrollable, Button, Column, Container, Row, Space, Text}; use iced::{Alignment, Length, Padding, Size, Vector}; use std::collections::HashMap; diff --git a/examples/overlay_editor/src/app/stroke/content.rs b/examples/overlay_editor/src/app/stroke/content.rs index 0d1fd498..90472ab4 100644 --- a/examples/overlay_editor/src/app/stroke/content.rs +++ b/examples/overlay_editor/src/app/stroke/content.rs @@ -8,8 +8,8 @@ use crate::point_editor::point::PathsToEditorPoints; use crate::point_editor::widget::PointEditUpdate; use i_triangle::i_overlay::i_float::int::point::IntPoint; use i_triangle::i_overlay::i_float::int::rect::IntRect; -use i_triangle::i_overlay::mesh::stroke::offset::StrokeOffset; -use i_triangle::i_overlay::mesh::style::{LineCap, LineJoin, StrokeStyle}; +use i_triangle::i_overlay::mesh::float::stroke::offset::StrokeOffset; +use i_triangle::i_overlay::mesh::float::style::{LineCap, LineJoin, StrokeStyle}; use iced::widget::{scrollable, Button, Column, Container, Row, Space, Text}; use iced::{Alignment, Length, Padding, Size, Vector}; use std::collections::HashMap; diff --git a/examples/overlay_editor/src/app/variable_stroke/content.rs b/examples/overlay_editor/src/app/variable_stroke/content.rs index aead8718..b921de09 100644 --- a/examples/overlay_editor/src/app/variable_stroke/content.rs +++ b/examples/overlay_editor/src/app/variable_stroke/content.rs @@ -7,8 +7,8 @@ use crate::point_editor::point::PathsToEditorPoints; use crate::point_editor::widget::PointEditUpdate; use i_triangle::i_overlay::i_float::int::point::IntPoint; use i_triangle::i_overlay::i_float::int::rect::IntRect; -use i_triangle::i_overlay::mesh::variable_stroke::offset::VariableStrokeOffset; -use i_triangle::i_overlay::mesh::variable_stroke::{StrokeVertex, VariableStrokeStyle}; +use i_triangle::i_overlay::mesh::float::variable_stroke::offset::VariableStrokeOffset; +use i_triangle::i_overlay::mesh::float::variable_stroke::{StrokeVertex, VariableStrokeStyle}; use iced::widget::{scrollable, Button, Column, Container, Row, Space, Text}; use iced::{Alignment, Length, Padding, Size, Vector}; use std::collections::HashMap; diff --git a/iOverlay/README.md b/iOverlay/README.md index 39535641..bc4b8e02 100644 --- a/iOverlay/README.md +++ b/iOverlay/README.md @@ -490,11 +490,26 @@ println!("result: {:?}", result); ## Buffering -Floating-point outline, stroke, and variable-width stroke APIs live under `mesh::float`. -Integer outline lives under `mesh::int::outline`, with styles in `mesh::int::style`. -Integer outline currently supports bevel joins; miter and round joins return `UnsupportedJoin`. -Use `validate_outline(&style)` for an optional conservative coordinate-range check. -Integer construction asserts the bounds in debug builds and trusts the caller in release builds. +Outline, stroke, and variable-width stroke geometry is built by `mesh::int`. +The existing `mesh::float` APIs select a scale, convert input and styles, call the +integer API, and convert the result back. Fixed-scale methods retain the supplied +grid. Integer rounding and CORDIC arc subdivision can change individual vertices +compared with the former float builders. + +Use `IntOutlineOffset`, `IntStrokeOffset`, or `IntVariableStrokeOffset` from the +corresponding `mesh::int::{outline,stroke,variable_stroke}::offset` module. +Outline and stroke styles live in `mesh::int::style`; variable-width vertices and +styles live in `mesh::int::variable_stroke`. Bevel, clipped miter, and round joins +are supported. Stroke caps can be butt, square, round, or custom; variable-width +strokes use round caps and joins. Round geometry uses `ArcOptions`, including +configurable CORDIC rotation precision (default: 5). + +Integer distances use input coordinate units without automatic rescaling. +Stroke radius is `ceil(max(width, 0) / 2)`; radii at most 1 are degenerate. +Use `validate_outline(&style)`, `validate_stroke(&style)`, or +`validate_variable_stroke()` for an optional conservative coordinate-range check. +Construction asserts bounds in debug builds and trusts the caller in release. +All three APIs provide `*_into` methods that replace a reusable flat output buffer. ### Offsetting a Path Path Example diff --git a/iOverlay/src/mesh/float/math.rs b/iOverlay/src/mesh/float/math.rs deleted file mode 100644 index f32475bb..00000000 --- a/iOverlay/src/mesh/float/math.rs +++ /dev/null @@ -1,19 +0,0 @@ -use i_float::float::compatible::FloatPointCompatible; -use i_float::float::vector::FloatPointMath; - -pub(crate) struct Math

{ - _phantom: core::marker::PhantomData

, -} -impl Math

{ - #[inline(always)] - pub(crate) fn normal(a: &P, b: &P) -> P { - let c = FloatPointMath::sub(a, b); - FloatPointMath::normalize(&c) - } - - #[inline(always)] - pub(crate) fn ortho_and_scale(p: &P, s: P::Scalar) -> P { - let t = P::from_xy(-p.y(), p.x()); - FloatPointMath::scale(&t, s) - } -} diff --git a/iOverlay/src/mesh/float/miter.rs b/iOverlay/src/mesh/float/miter.rs deleted file mode 100644 index 55a2c51c..00000000 --- a/iOverlay/src/mesh/float/miter.rs +++ /dev/null @@ -1,67 +0,0 @@ -use i_float::adapter::FloatPointAdapter; -use i_float::float::compatible::FloatPointCompatible; -use i_float::float::number::FloatNumber; -use i_float::int::number::int::IntNumber; -use i_float::int::point::IntPoint; - -pub(super) struct Miter; - -pub(super) enum SharpMiter { - Degenerate, - AB(IntPoint, IntPoint), - AcB(IntPoint, IntPoint, IntPoint), -} - -impl Miter { - #[inline] - pub(super) fn sharp( - pa: P, - pb: P, - va: P, - vb: P, - adapter: &FloatPointAdapter, - ) -> SharpMiter { - let ia = adapter.float_to_int(&pa); - let ib = adapter.float_to_int(&pb); - - if ia == ib { - return SharpMiter::Degenerate; - } - - let c = Self::peak(pa, pb, va, vb); - - let ic = adapter.float_to_int(&c); - - if ia == ic || ib == ic { - SharpMiter::AB(ia, ib) - } else { - SharpMiter::AcB(ia, ic, ib) - } - } - - #[inline] - pub(super) fn peak(pa: P, pb: P, va: P, vb: P) -> P { - let pax = pa.x(); - let pay = pa.y(); - let pbx = pb.x(); - let pby = pb.y(); - let vax = va.x(); - let vay = va.y(); - let vbx = vb.x(); - let vby = vb.y(); - - let xx = vax + vbx; - let yy = vay + vby; - - let k = if xx.abs() > yy.abs() { - (pbx - pax) / xx - } else { - (pby - pay) / yy - }; - - let x = pax + k * vax; - let y = pay + k * vay; - - P::from_xy(x, y) - } -} diff --git a/iOverlay/src/mesh/float/mod.rs b/iOverlay/src/mesh/float/mod.rs index 11c5bac6..79a1f1c0 100644 --- a/iOverlay/src/mesh/float/mod.rs +++ b/iOverlay/src/mesh/float/mod.rs @@ -1,8 +1,5 @@ //! Mesh operations on floating-point input geometry. -pub(crate) mod math; -mod miter; pub mod outline; -mod rotator; pub mod stroke; pub mod style; pub mod variable_stroke; diff --git a/iOverlay/src/mesh/float/outline/builder.rs b/iOverlay/src/mesh/float/outline/builder.rs deleted file mode 100644 index 12ed9a6c..00000000 --- a/iOverlay/src/mesh/float/outline/builder.rs +++ /dev/null @@ -1,275 +0,0 @@ -use crate::mesh::float::math::Math; -use crate::mesh::float::outline::builder_join::JoinBuilder; -use crate::mesh::float::outline::builder_join::{BevelJoinBuilder, MiterJoinBuilder, RoundJoinBuilder}; -use crate::mesh::float::outline::section::OffsetSection; -use crate::mesh::float::style::LineJoin; -use crate::mesh::uniq_iter::{UniqueSegment, UniqueSegmentsIter}; -use crate::segm::boolean::ShapeCountBoolean; -use crate::segm::segment::Segment; -use alloc::boxed::Box; -use alloc::vec::Vec; -use core::marker::PhantomData; -use i_float::adapter::FloatPointAdapter; -use i_float::float::compatible::FloatPointCompatible; -use i_float::float::number::FloatNumber; -use i_float::float::vector::FloatPointMath; -use i_float::int::number::int::IntNumber; -use i_float::int::number::wide_int::WideIntNumber; - -trait OutlineBuild { - fn build( - &self, - path: &[P], - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ); - - fn capacity(&self, points_count: usize) -> usize; - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar; -} - -pub(super) struct OutlineBuilder { - builder: Box>, -} - -struct Builder, P: FloatPointCompatible, I: IntNumber> { - extend: bool, - radius: P::Scalar, - join_builder: J, - _phantom: PhantomData<(P, I)>, -} - -impl OutlineBuilder { - pub(super) fn new(radius: P::Scalar, join: &LineJoin) -> OutlineBuilder { - let extend = radius > P::Scalar::from_float(0.0); - let builder: Box> = { - match join { - LineJoin::Miter(ratio) => Box::new(Builder { - extend, - radius, - join_builder: MiterJoinBuilder::new(*ratio, radius), - _phantom: Default::default(), - }), - LineJoin::Round(ratio) => Box::new(Builder { - extend, - radius, - join_builder: RoundJoinBuilder::new(*ratio, radius), - _phantom: Default::default(), - }), - LineJoin::Bevel => Box::new(Builder { - extend, - radius, - join_builder: BevelJoinBuilder {}, - _phantom: Default::default(), - }), - } - }; - - Self { builder } - } - - #[inline] - pub(super) fn build( - &self, - path: &[P], - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - self.builder.build(path, adapter, segments); - } - - #[inline] - pub(super) fn capacity(&self, points_count: usize) -> usize { - self.builder.capacity(points_count) - } - - #[inline] - pub(super) fn additional_offset(&self, radius: P::Scalar) -> P::Scalar { - self.builder.additional_offset(radius) - } -} - -impl, P: FloatPointCompatible, I: IntNumber> OutlineBuild for Builder { - #[inline] - fn build( - &self, - path: &[P], - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - if path.len() < 2 { - return; - } - - self.build(path, adapter, segments); - } - - #[inline] - fn capacity(&self, points_count: usize) -> usize { - self.join_builder.capacity() * points_count - } - - #[inline] - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar { - self.join_builder.additional_offset(radius) - } -} - -impl, P: FloatPointCompatible, I: IntNumber> Builder { - fn build( - &self, - path: &[P], - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - let iter = path.iter().map(|p| adapter.float_to_int(p)); - let mut uniq_segments = if let Some(iter) = UniqueSegmentsIter::new(iter) { - iter - } else { - return; - }; - - let us0 = if let Some(us) = uniq_segments.next() { - us - } else { - return; - }; - - let s0 = OffsetSection::new(self.radius, &us0, adapter); - let mut sk = s0.clone(); - - segments.push_some(sk.top_segment()); - - for usi in uniq_segments { - let si = OffsetSection::new(self.radius, &usi, adapter); - segments.push_some(si.top_segment()); - self.feed_join(&sk, &si, adapter, segments); - sk = si; - } - self.feed_join(&sk, &s0, adapter, segments); - } - - #[inline] - fn feed_join( - &self, - s0: &OffsetSection, - s1: &OffsetSection, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - let vi = s1.b - s1.a; - let vp = s0.b - s0.a; - - let cross = vi.cross_product(vp); - - let outer_corner = if cross != I::Wide::ZERO { - (cross > I::Wide::ZERO) == self.extend - } else { - vi.dot_product(vp) < I::Wide::ZERO - }; - - if outer_corner { - if s0.b_top != s1.a_top { - self.join_builder.add_join(s0, s1, adapter, segments); - } - } else { - // no join - segments.push_some(s0.b_segment()); - segments.push_some(s1.a_segment()); - } - } -} - -impl OffsetSection { - #[inline] - fn new(radius: P::Scalar, s: &UniqueSegment, adapter: &FloatPointAdapter) -> Self { - let a = adapter.int_to_float(&s.a); - let b = adapter.int_to_float(&s.b); - let ab = FloatPointMath::sub(&b, &a); - let dir = FloatPointMath::normalize(&ab); - let t = Math::ortho_and_scale(&dir, radius); - - let at = FloatPointMath::add(&a, &t); - let bt = FloatPointMath::add(&b, &t); - let a_top = adapter.float_to_int(&at); - let b_top = adapter.float_to_int(&bt); - - Self { - a: s.a, - b: s.b, - a_top, - b_top, - dir, - } - } -} - -trait VecPushSome { - fn push_some(&mut self, value: Option); -} - -impl VecPushSome for Vec { - #[inline] - fn push_some(&mut self, value: Option) { - if let Some(v) = value { - self.push(v); - } - } -} - -#[cfg(test)] -mod tests { - use crate::mesh::float::outline::builder::OutlineBuilder; - use crate::mesh::float::style::LineJoin; - use crate::segm::boolean::ShapeCountBoolean; - use crate::segm::segment::Segment; - use alloc::vec::Vec; - use i_float::adapter::FloatPointAdapter; - use i_float::float::rect::FloatRect; - - #[test] - fn test_i64_builder() { - let path = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; - let mut rect = FloatRect::with_iter(path.iter()).unwrap(); - rect.add_offset(2.0); - let adapter = FloatPointAdapter::<[f64; 2], i64>::new(rect); - let builder = OutlineBuilder::<[f64; 2], i64>::new(1.0, &LineJoin::Bevel); - - let mut segments = Vec::>::new(); - builder.build(&path, &adapter, &mut segments); - - assert!(!segments.is_empty()); - } -} - -#[cfg(test)] -mod non_degenerate_tests { - use super::*; - use i_float::float::rect::FloatRect; - - #[test] - fn outline_styles_skip_coincident_integer_endpoints() { - let adapter = - FloatPointAdapter::<[f64; 2], i32>::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 1.0); - for radius in [-2.0, -0.1, 0.0, 0.1, 2.0] { - for join in [ - LineJoin::Bevel, - LineJoin::Miter(0.1), - LineJoin::Miter(3.0), - LineJoin::Round(0.01), - ] { - let builder = OutlineBuilder::new(radius, &join); - for end in [[10.0, 10.0], [0.0, 0.0], [20.0, 0.1]] { - let path = [[0.0, 0.0], [0.0, 0.0], [0.01, 0.01], [10.0, 0.0], end]; - let mut segments = Vec::new(); - builder.build(&path, &adapter, &mut segments); - assert!(!segments.is_empty()); - assert!( - segments.iter().all(|s| s.x_segment.a < s.x_segment.b), - "radius={radius}, join={join:?}, end={end:?}" - ); - } - } - } - } -} diff --git a/iOverlay/src/mesh/float/outline/builder_join.rs b/iOverlay/src/mesh/float/outline/builder_join.rs deleted file mode 100644 index 81efb1e0..00000000 --- a/iOverlay/src/mesh/float/outline/builder_join.rs +++ /dev/null @@ -1,251 +0,0 @@ -use crate::mesh::float::miter::Miter; -use crate::mesh::float::outline::section::OffsetSection; -use crate::mesh::float::rotator::Rotator; -use crate::segm::boolean::ShapeCountBoolean; -use crate::segm::segment::Segment; -use alloc::vec::Vec; -use core::f64::consts::PI; -use i_float::adapter::FloatPointAdapter; -use i_float::float::compatible::FloatPointCompatible; -use i_float::float::number::FloatNumber; -use i_float::float::vector::FloatPointMath; -use i_float::int::number::int::IntNumber; -use i_float::int::number::uint::UIntNumber; - -pub(super) trait JoinBuilder { - fn add_join( - &self, - s0: &OffsetSection, - s1: &OffsetSection, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ); - fn capacity(&self) -> usize; - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar; -} - -pub(super) struct BevelJoinBuilder; - -impl BevelJoinBuilder { - #[inline] - fn join( - s0: &OffsetSection, - s1: &OffsetSection, - _adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - debug_assert!(s0.b_top != s1.a_top, "must be validated before"); - segments.push(Segment::subject(s0.b_top, s1.a_top)); - } -} - -impl JoinBuilder for BevelJoinBuilder { - #[inline] - fn add_join( - &self, - s0: &OffsetSection, - s1: &OffsetSection, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - Self::join(s0, s1, adapter, segments); - } - - #[inline] - fn capacity(&self) -> usize { - 2 - } - - #[inline] - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar { - // add extra 10% to avoid problems with floating point precision. - P::Scalar::from_float(1.1) * radius - } -} - -pub(super) struct MiterJoinBuilder { - limit_dot_product: T, - max_offset: T, - max_length: T, -} - -impl MiterJoinBuilder { - pub(super) fn new(angle: T, radius: T) -> Self { - // angle - min possible angle - let fixed_angle = angle.to_f64().max(0.01); - let limit_dot_product = -T::from_float(fixed_angle.cos()); - - let half_angle = 0.5 * fixed_angle; - let tan = half_angle.tan(); - - let r = radius.to_f64().abs(); - let l = r / tan; - - // add extra 10% to avoid problems with floating point precision. - let max_offset = T::from_float(1.1 * (r * r + l * l).sqrt()); - let max_length = T::from_float(l); - - Self { - limit_dot_product, - max_offset, - max_length, - } - } -} - -impl JoinBuilder for MiterJoinBuilder { - fn add_join( - &self, - s0: &OffsetSection, - s1: &OffsetSection, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - let ia = s0.b_top; - let ib = s1.a_top; - - let sq_len = ia.sqr_distance(ib); - if sq_len < I::WideUInt::from_u64(4) { - BevelJoinBuilder::join(s0, s1, adapter, segments); - return; - } - - let dot_product = FloatPointMath::dot_product(&s0.dir, &s1.dir); - let is_limited = self.limit_dot_product > dot_product; - - let pa = adapter.int_to_float(&ia); - let pb = adapter.int_to_float(&ib); - - if is_limited { - let (va, vb) = (s0.dir, s1.dir); - - let ax = pa.x() + self.max_length * va.x(); - let ay = pa.y() + self.max_length * va.y(); - let bx = pb.x() - self.max_length * vb.x(); - let by = pb.y() - self.max_length * vb.y(); - - let ac = P::from_xy(ax, ay); - let bc = P::from_xy(bx, by); - - let iac = adapter.float_to_int(&ac); - let ibc = adapter.float_to_int(&bc); - - if ia != iac { - segments.push(Segment::subject(ia, iac)); - } - if iac != ibc { - segments.push(Segment::subject(iac, ibc)); - } - if ibc != ib { - segments.push(Segment::subject(ibc, ib)); - } - } else { - let c = Miter::peak(pa, pb, s0.dir, s1.dir); - debug_assert!(ia != ib); - - let ic = adapter.float_to_int(&c); - if ia == ic || ib == ic { - segments.push(Segment::subject(ia, ib)) - } else { - segments.push(Segment::subject(ia, ic)); - segments.push(Segment::subject(ic, ib)); - } - } - } - - #[inline] - fn capacity(&self) -> usize { - 4 - } - - #[inline] - fn additional_offset(&self, _radius: P::Scalar) -> P::Scalar { - self.max_offset - } -} - -pub(super) struct RoundJoinBuilder { - inv_ratio: T, - average_count: usize, - radius: T, - limit_dot_product: T, - rot_dir: T, -} - -impl RoundJoinBuilder { - pub(super) fn new(ratio: T, radius: T) -> Self { - // ratio = A / R - let fixed_ratio = ratio.min(T::from_float(0.25 * PI)); - let limit_dot_product = fixed_ratio.cos(); - let average_count = (T::from_float(0.6 * PI) / fixed_ratio).to_usize() + 2; - let rot_dir = if radius >= T::from_float(0.0) { - T::from_float(-1.0) - } else { - T::from_float(1.0) - }; - - Self { - inv_ratio: T::from_float(1.0) / fixed_ratio, - average_count, - radius, - limit_dot_product, - rot_dir, - } - } -} -impl JoinBuilder for RoundJoinBuilder { - fn add_join( - &self, - s0: &OffsetSection, - s1: &OffsetSection, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - let dot_product = FloatPointMath::dot_product(&s0.dir, &s1.dir); - if self.limit_dot_product < dot_product { - BevelJoinBuilder::join(s0, s1, adapter, segments); - return; - } - - // Normalization can round the dot product outside [-1, 1], making acos return NaN. - let angle = dot_product.max(-P::Scalar::ONE).min(P::Scalar::ONE).acos(); - let n = (angle * self.inv_ratio).to_usize(); - let delta_angle = angle / P::Scalar::from_usize(n); - - let start = s0.b_top; - let end = s1.a_top; - - let dir = P::from_xy(-s0.dir.y(), s0.dir.x()); - - let rotator = Rotator::::with_angle(self.rot_dir * delta_angle); - - let center = adapter.int_to_float(&s0.b); - let mut v = dir; - let mut a = start; - for _ in 1..n { - v = rotator.rotate(&v); - let p = FloatPointMath::add(¢er, &FloatPointMath::scale(&v, self.radius)); - - let b = adapter.float_to_int(&p); - if a != b { - segments.push(Segment::subject(a, b)); - a = b; - } - } - - if a != end { - segments.push(Segment::subject(a, end)); - } - } - - #[inline] - fn capacity(&self) -> usize { - self.average_count - } - - #[inline] - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar { - // add extra 10% to avoid problems with floating point precision. - P::Scalar::from_float(1.1) * radius - } -} diff --git a/iOverlay/src/mesh/float/outline/mod.rs b/iOverlay/src/mesh/float/outline/mod.rs index adf73534..01590761 100644 --- a/iOverlay/src/mesh/float/outline/mod.rs +++ b/iOverlay/src/mesh/float/outline/mod.rs @@ -1,4 +1 @@ -mod builder; -mod builder_join; pub mod offset; -mod section; diff --git a/iOverlay/src/mesh/float/outline/offset.rs b/iOverlay/src/mesh/float/outline/offset.rs index 4222bda8..dd62f463 100644 --- a/iOverlay/src/mesh/float/outline/offset.rs +++ b/iOverlay/src/mesh/float/outline/offset.rs @@ -1,28 +1,20 @@ -use crate::core::extract::BooleanExtractionBuffer; -use crate::core::fill_rule::FillRule; use crate::core::integer::OverlayInt; -use crate::core::overlay::ShapeType::Subject; -use crate::core::overlay::{ContourDirection, Overlay}; -use crate::core::overlay_rule::OverlayRule; use crate::float::overlay::OverlayOptions; use crate::float::scale::FixedScaleOverlayError; -use crate::mesh::float::outline::builder::OutlineBuilder; use crate::mesh::float::style::OutlineStyle; +use crate::mesh::int::outline::offset::IntOutlineOffset; +use crate::mesh::int::style::IntOutlineStyle; use alloc::vec; -use alloc::vec::Vec; use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; use i_float::float::number::FloatNumber; use i_float::float::rect::FloatRect; use i_float::int::number::int::IntNumber; -use i_float::int::number::uint::UIntNumber; -use i_float::int::number::wide_int::WideIntNumber; use i_shape::base::data::Shapes; use i_shape::flat::buffer::FlatContoursBuffer; use i_shape::flat::float::FloatFlatContoursBuffer; -use i_shape::float::adapter::ShapesToFloat; +use i_shape::float::adapter::{ResourceToInt, ShapesToFloat}; use i_shape::float::despike::DeSpikeContour; -use i_shape::float::int_area::IntArea; use i_shape::float::simple::SimplifyContour; use i_shape::source::float::resource::ShapeResource; @@ -407,10 +399,8 @@ where } struct OutlineSolver { - outer_builder: OutlineBuilder, - inner_builder: OutlineBuilder, + style: OutlineStyle, adapter: FloatPointAdapter, - points_count: usize, } impl OutlineSolver @@ -419,42 +409,25 @@ where I: OverlayInt + 'static, { fn prepare>(source: &S, style: &OutlineStyle) -> Option { - let (points_count, paths_count) = { - let mut points_count = 0; - let mut paths_count = 0; - for path in source.iter_paths() { - points_count += path.len(); - paths_count += 1; - } - (points_count, paths_count) - }; - - if paths_count == 0 { + if source.iter_paths().next().is_none() { return None; } - let join = style.join.clone().normalize(); - let outer_builder: OutlineBuilder = OutlineBuilder::new(-style.outer_offset, &join); - let inner_builder: OutlineBuilder = OutlineBuilder::new(-style.inner_offset, &join); - - let outer_radius = style.outer_offset; - let inner_radius = style.inner_offset; - - let outer_additional_offset = outer_builder.additional_offset(outer_radius); - let inner_additional_offset = inner_builder.additional_offset(inner_radius); - - let additional_offset = outer_additional_offset.abs() + inner_additional_offset.abs(); - + let additional_offset = P::Scalar::from_float( + (style.outer_offset.to_f64().abs() + style.inner_offset.to_f64().abs()) * style.join.padding(), + ); let mut rect = FloatRect::with_iter(source.iter_paths().flatten()).unwrap_or(FloatRect::zero()); rect.add_offset(additional_offset); let adapter = FloatPointAdapter::::new(rect); Some(Self { - outer_builder, - inner_builder, + style: OutlineStyle { + outer_offset: style.outer_offset, + inner_offset: style.inner_offset, + join: style.join.clone(), + }, adapter, - points_count, }) } @@ -464,67 +437,21 @@ where Ok(()) } - fn build_overlay>( - &self, - source: &S, - options: OverlayOptions, - ) -> Overlay { - let total_capacity = self.outer_builder.capacity(self.points_count); - let mut overlay = Overlay::new_custom( - total_capacity, - options.int_with_adapter(&self.adapter), - Default::default(), - ); - - let mut offset_overlay = Overlay::new(16); - offset_overlay.options = overlay.options; - // Small offset contours can merge into a larger result; filter area only after union. - offset_overlay.options.min_output_area = I::WideUInt::ZERO; - - let mut segments = Vec::new(); - let mut bool_buffer = BooleanExtractionBuffer::default(); - let mut flat_buffer = FlatContoursBuffer::::with_capacity(0); - - for path in source.iter_paths() { - let area = path.unsafe_int_area(&self.adapter); - if area == I::Wide::ZERO { - // ignore degenerate paths - continue; - } - - offset_overlay.clear(); - segments.clear(); - - let contour_fill_rule = if area > I::Wide::ZERO { - offset_overlay.options.output_direction = ContourDirection::CounterClockwise; - segments.reserve(self.outer_builder.capacity(path.len())); - self.outer_builder.build(path, &self.adapter, &mut segments); - FillRule::Positive - } else { - offset_overlay.options.output_direction = ContourDirection::Clockwise; - segments.reserve(self.inner_builder.capacity(path.len())); - self.inner_builder.build(path, &self.adapter, &mut segments); - - FillRule::Negative - }; - - offset_overlay.add_segments(&segments); - - if let Some(graph) = offset_overlay.build_graph_view(contour_fill_rule) { - graph.extract_contours_into(OverlayRule::Subject, &mut bool_buffer, &mut flat_buffer); - } - - overlay.add_flat_buffer(&flat_buffer, Subject); + fn int_style(&self) -> IntOutlineStyle { + IntOutlineStyle { + outer_offset: self.adapter.round_len_to_int(self.style.outer_offset), + inner_offset: self.adapter.round_len_to_int(self.style.inner_offset), + join: (&self.style.join).into(), } - - overlay } fn build>(self, source: &S, options: OverlayOptions) -> Shapes

{ let preserve_output_collinear = options.preserve_output_collinear; let clean_result = options.clean_result; - let mut overlay = self.build_overlay(source, options); - let shapes = overlay.overlay(OverlayRule::Subject, FillRule::Positive); + let paths = source.to_int_paths(&self.adapter); + let shapes = paths + .outline_custom(&self.int_style(), options.int_with_adapter(&self.adapter)) + .expect("integer outline supports every float join"); if clean_result { let mut float = shapes.to_float(&self.adapter); @@ -547,10 +474,15 @@ where ) { let preserve_output_collinear = options.preserve_output_collinear; let clean_result = options.clean_result; - let mut overlay = self.build_overlay(source, options); - + let paths = source.to_int_paths(&self.adapter); let mut int_output = FlatContoursBuffer::::with_capacity(0); - overlay.overlay_into(OverlayRule::Subject, FillRule::Positive, &mut int_output); + paths + .outline_custom_into( + &self.int_style(), + options.int_with_adapter(&self.adapter), + &mut int_output, + ) + .expect("integer outline supports every float join"); let iter = int_output.points.iter().map(|p| self.adapter.int_to_float(p)); output.set_with_iter(iter, &int_output.ranges); @@ -1109,7 +1041,8 @@ mod tests { assert_eq!(shape.len(), 1); let path = shape.first().unwrap(); - assert_eq!(path.len(), 8); + assert_eq!(path.len(), 4); + assert!(path.iter().all(|p| p[0].abs() == 11.0 && p[1].abs() == 11.0)); let result_sign = path.area().signum(); assert_eq!(original_sign, result_sign); diff --git a/iOverlay/src/mesh/float/outline/section.rs b/iOverlay/src/mesh/float/outline/section.rs deleted file mode 100644 index 9f95ad58..00000000 --- a/iOverlay/src/mesh/float/outline/section.rs +++ /dev/null @@ -1,43 +0,0 @@ -use crate::segm::boolean::ShapeCountBoolean; -use crate::segm::segment::Segment; -use i_float::float::compatible::FloatPointCompatible; -use i_float::int::number::int::IntNumber; -use i_float::int::point::IntPoint; - -#[derive(Clone)] -pub(super) struct OffsetSection { - pub(super) a: IntPoint, - pub(super) b: IntPoint, - pub(super) a_top: IntPoint, - pub(super) b_top: IntPoint, - pub(super) dir: P, -} - -impl OffsetSection { - #[inline] - pub(super) fn top_segment(&self) -> Option> { - if self.a_top != self.b_top { - Some(Segment::subject(self.a_top, self.b_top)) - } else { - None - } - } - - #[inline] - pub(super) fn a_segment(&self) -> Option> { - if self.a_top != self.a { - Some(Segment::subject(self.a, self.a_top)) - } else { - None - } - } - - #[inline] - pub(super) fn b_segment(&self) -> Option> { - if self.b_top != self.b { - Some(Segment::subject(self.b_top, self.b)) - } else { - None - } - } -} diff --git a/iOverlay/src/mesh/float/rotator.rs b/iOverlay/src/mesh/float/rotator.rs deleted file mode 100644 index 767e1780..00000000 --- a/iOverlay/src/mesh/float/rotator.rs +++ /dev/null @@ -1,84 +0,0 @@ -use i_float::float::compatible::FloatPointCompatible; -use i_float::float::number::FloatNumber; - -pub(crate) struct Rotator { - a_x: T, - a_y: T, - b_x: T, - b_y: T, -} - -impl Rotator { - #[inline] - pub(crate) fn new(cs: T, sn: T) -> Self { - let a_x = cs; - let a_y = sn; - let b_x = -a_y; - let b_y = a_x; - - Self { a_x, a_y, b_x, b_y } - } - - #[inline] - pub(crate) fn with_angle(angle: T) -> Self { - let (sin, cos) = angle.sin_cos(); - Self::new(cos, sin) - } - - #[inline] - pub(crate) fn with_vector>(v: &P) -> Self { - Self::new(v.x(), v.y()) - } - - #[inline] - pub(crate) fn rotate>(&self, v: &P) -> P { - let v_x = v.x(); - let v_y = v.y(); - let x = self.a_x * v_x + self.b_x * v_y; - let y = self.a_y * v_x + self.b_y * v_y; - P::from_xy(x, y) - } -} - -#[cfg(test)] -mod tests { - use crate::mesh::float::rotator::Rotator; - use core::f64::consts::PI; - - #[test] - fn test_ccw_rotate() { - let deg_45 = 0.25 * PI; - let rotator = Rotator::with_angle(deg_45); - let v0 = [1.0, 0.0]; - let v1 = rotator.rotate(&v0); - let v2 = rotator.rotate(&v1); - let v3 = rotator.rotate(&v2); - - let i_sqrt2 = 1.0 / 2.0f64.sqrt(); - - compare_vecs(v1, [i_sqrt2, i_sqrt2]); - compare_vecs(v2, [0.0, 1.0]); - compare_vecs(v3, [-i_sqrt2, i_sqrt2]); - } - - #[test] - fn test_cw_rotate() { - let deg_45 = -0.25 * PI; - let rotator = Rotator::with_angle(deg_45); - let v0 = [1.0, 0.0]; - let v1 = rotator.rotate(&v0); - let v2 = rotator.rotate(&v1); - let v3 = rotator.rotate(&v2); - - let i_sqrt2 = 1.0 / 2.0f64.sqrt(); - - compare_vecs(v1, [i_sqrt2, -i_sqrt2]); - compare_vecs(v2, [0.0, -1.0]); - compare_vecs(v3, [-i_sqrt2, -i_sqrt2]); - } - - fn compare_vecs(v0: [f64; 2], v1: [f64; 2]) { - assert!((v0[0] - v1[0]).abs() < 0.0001); - assert!((v0[1] - v1[1]).abs() < 0.0001); - } -} diff --git a/iOverlay/src/mesh/float/stroke/builder.rs b/iOverlay/src/mesh/float/stroke/builder.rs deleted file mode 100644 index 92a075b4..00000000 --- a/iOverlay/src/mesh/float/stroke/builder.rs +++ /dev/null @@ -1,310 +0,0 @@ -use crate::mesh::float::stroke::builder_cap::CapBuilder; -use crate::mesh::float::stroke::builder_join::{ - BevelJoinBuilder, JoinBuilder, MiterJoinBuilder, RoundJoinBuilder, -}; -use crate::mesh::float::stroke::section::{Section, SectionToSegment}; -use crate::mesh::float::style::{LineJoin, StrokeStyle}; -use crate::segm::boolean::ShapeCountBoolean; -use crate::segm::segment::Segment; -use alloc::boxed::Box; -use alloc::vec::Vec; -use core::marker::PhantomData; -use i_float::adapter::FloatPointAdapter; -use i_float::float::compatible::FloatPointCompatible; -use i_float::float::number::FloatNumber; -use i_float::int::number::int::IntNumber; - -trait StrokeBuild { - fn build( - &self, - path: &[P], - is_closed_path: bool, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ); - - fn capacity(&self, paths_count: usize, points_count: usize, is_closed_path: bool) -> usize; - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar; -} - -pub(super) struct StrokeBuilder { - builder: Box>, -} - -struct Builder, P: FloatPointCompatible, I: IntNumber> { - radius: P::Scalar, - join_builder: J, - start_cap_builder: CapBuilder

, - end_cap_builder: CapBuilder

, - _phantom: PhantomData, -} - -impl StrokeBuilder { - pub(super) fn new(style: StrokeStyle

) -> StrokeBuilder { - let radius = P::Scalar::from_float(0.5 * style.width.to_f64().max(0.0)); - - let start_cap_builder = CapBuilder::new(style.start_cap.normalize(), radius); - let end_cap_builder = CapBuilder::new(style.end_cap.normalize(), radius); - - let builder: Box> = match style.join.normalize() { - LineJoin::Miter(ratio) => Box::new(Builder { - radius, - join_builder: MiterJoinBuilder::new(ratio, radius), - start_cap_builder, - end_cap_builder, - _phantom: Default::default(), - }), - LineJoin::Round(ratio) => Box::new(Builder { - radius, - join_builder: RoundJoinBuilder::new(ratio, radius), - start_cap_builder, - end_cap_builder, - _phantom: Default::default(), - }), - LineJoin::Bevel => Box::new(Builder { - radius, - join_builder: BevelJoinBuilder {}, - start_cap_builder, - end_cap_builder, - _phantom: Default::default(), - }), - }; - - Self { builder } - } - - #[inline] - pub(super) fn build( - &self, - path: &[P], - is_closed_path: bool, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - self.builder.build(path, is_closed_path, adapter, segments); - } - - #[inline] - pub(super) fn capacity(&self, paths_count: usize, points_count: usize, is_closed_path: bool) -> usize { - self.builder.capacity(paths_count, points_count, is_closed_path) - } - - #[inline] - pub(super) fn additional_offset(&self, radius: P::Scalar) -> P::Scalar { - self.builder.additional_offset(radius) - } -} - -impl, P: FloatPointCompatible, I: IntNumber> StrokeBuild for Builder { - #[inline] - fn build( - &self, - path: &[P], - is_closed_path: bool, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - if is_closed_path { - self.closed_segments(path, adapter, segments); - } else { - self.open_segments(path, adapter, segments); - } - } - - #[inline] - fn capacity(&self, paths_count: usize, points_count: usize, is_closed_path: bool) -> usize { - if is_closed_path { - (self.join_builder.capacity() * points_count).saturating_sub(2) - } else { - self.join_builder.capacity() * (points_count.saturating_sub(1)) - + paths_count * (self.end_cap_builder.capacity() + self.start_cap_builder.capacity()) - } - } - - #[inline] - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar { - let start_cap = self.start_cap_builder.additional_offset(); - let end_cap = self.end_cap_builder.additional_offset(); - let join = self.join_builder.additional_offset(radius); - join.max(start_cap.max(end_cap)) - } -} - -impl, P: FloatPointCompatible, I: IntNumber> Builder { - fn open_segments( - &self, - path: &[P], - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - // build segments only from points which are not equal in int space - - let n = path.len(); - if n < 2 { - return; - } - - let mut ip0 = adapter.float_to_int(&path[0]); - let mut ip = adapter.float_to_int(&path[1]); - let mut j = 1; - while ip == ip0 { - j += 1; - if j >= n { - return; - } - ip = adapter.float_to_int(&path[j]); - } - - let mut s0 = Section::new(self.radius, &path[0], &path[j]); - - self.start_cap_builder.add_to_start(&s0, adapter, segments); - - segments.add_section(&s0, adapter); - - ip0 = ip; - j += 1; - 'main_loop: while j < n { - let mut p = &path[j]; - ip = adapter.float_to_int(p); - while ip == ip0 { - j += 1; - if j >= n { - break 'main_loop; - } - p = &path[j]; - ip = adapter.float_to_int(p); - } - let s1 = Section::new(self.radius, &s0.b, p); - self.join_builder.add_join(&s0, &s1, adapter, segments); - segments.add_section(&s1, adapter); - s0 = s1; - ip0 = ip; - } - - self.end_cap_builder.add_to_end(&s0, adapter, segments); - } - - fn closed_segments( - &self, - path: &[P], - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - if path.len() < 2 { - return; - } - - // build segments only from points which are not equal in int space - let i0 = path.len() - 1; - let i1 = Self::next_unique_point(i0, 0, path, adapter); - if i1 == usize::MAX { - return; - } - - let start = Section::new(self.radius, &path[i0], &path[i1]); - let mut s0 = start.clone(); - segments.add_section(&s0, adapter); - - let mut i = i1; - i = Self::next_unique_point(i, i + 1, path, adapter); - while i != usize::MAX { - let si = Section::new(self.radius, &s0.b, &path[i]); - self.join_builder.add_join(&s0, &si, adapter, segments); - segments.add_section(&si, adapter); - - i = Self::next_unique_point(i, i + 1, path, adapter); - s0 = si; - } - - self.join_builder.add_join(&s0, &start, adapter, segments); - } - - #[inline] - fn next_unique_point(start: usize, index: usize, path: &[P], adapter: &FloatPointAdapter) -> usize { - let a = adapter.float_to_int(&path[start]); - for (j, p) in path.iter().enumerate().skip(index) { - let b = adapter.float_to_int(p); - if a != b { - return j; - } - } - - usize::MAX - } -} - -#[cfg(test)] -mod tests { - use crate::mesh::float::stroke::builder::StrokeBuilder; - use crate::mesh::float::style::StrokeStyle; - use crate::segm::boolean::ShapeCountBoolean; - use crate::segm::segment::Segment; - use alloc::vec::Vec; - use i_float::adapter::FloatPointAdapter; - use i_float::float::rect::FloatRect; - - #[test] - fn test_i64_builder() { - let path = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]]; - let mut rect = FloatRect::with_iter(path.iter()).unwrap(); - rect.add_offset(2.0); - let adapter = FloatPointAdapter::<[f64; 2], i64>::new(rect); - let builder = StrokeBuilder::<[f64; 2], i64>::new(StrokeStyle::new(2.0)); - - let mut segments = Vec::>::new(); - builder.build(&path, false, &adapter, &mut segments); - - assert!(!segments.is_empty()); - } -} - -#[cfg(test)] -mod non_degenerate_tests { - use super::*; - use crate::mesh::float::style::LineCap; - use alloc::rc::Rc; - use i_float::float::rect::FloatRect; - - #[test] - fn stroke_styles_skip_coincident_integer_endpoints() { - let adapter = - FloatPointAdapter::<[f64; 2], i32>::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 1.0); - let caps = [ - LineCap::Butt, - LineCap::Square, - LineCap::Round(0.01), - LineCap::Custom(Rc::from([[0.0, -1.0], [0.0, -1.0], [0.01, -1.0], [0.0, 1.0]])), - ]; - for width in [0.0, 0.1, 1.0, 4.0] { - for join in [ - LineJoin::Bevel, - LineJoin::Miter(0.1), - LineJoin::Miter(3.0), - LineJoin::Round(0.01), - ] { - for cap in &caps { - let builder = StrokeBuilder::new( - StrokeStyle::new(width) - .line_join(join.clone()) - .start_cap(cap.clone()) - .end_cap(cap.clone()), - ); - for end in [[10.0, 10.0], [0.0, 0.0], [20.0, 0.1]] { - let path = [[0.0, 0.0], [0.0, 0.0], [0.01, 0.01], [10.0, 0.0], end]; - for closed in [false, true] { - let mut segments = Vec::new(); - builder.build(&path, closed, &adapter, &mut segments); - assert!( - segments.iter().all(|s| s.x_segment.a < s.x_segment.b), - "width={width}, join={join:?}, cap={cap:?}, closed={closed}, end={end:?}" - ); - if width == 4.0 { - assert!(!segments.is_empty()); - } - } - } - } - } - } - } -} diff --git a/iOverlay/src/mesh/float/stroke/builder_cap.rs b/iOverlay/src/mesh/float/stroke/builder_cap.rs deleted file mode 100644 index 967ab9de..00000000 --- a/iOverlay/src/mesh/float/stroke/builder_cap.rs +++ /dev/null @@ -1,178 +0,0 @@ -use crate::mesh::float::rotator::Rotator; -use crate::mesh::float::stroke::section::Section; -use crate::mesh::float::style::LineCap; -use crate::segm::boolean::ShapeCountBoolean; -use crate::segm::segment::Segment; -use alloc::vec; -use alloc::vec::Vec; -use core::f64::consts::PI; -use i_float::adapter::FloatPointAdapter; -use i_float::float::compatible::FloatPointCompatible; -use i_float::float::number::FloatNumber; -use i_float::float::vector::FloatPointMath; -use i_float::int::number::int::IntNumber; - -pub(super) struct CapBuilder

{ - points: Option>, -} - -impl CapBuilder

{ - pub(super) fn new(cap: LineCap

, radius: P::Scalar) -> Self { - let points = match cap { - LineCap::Butt => None, - LineCap::Round(ratio) => Some(Self::round_points(ratio, radius)), - LineCap::Square => Some(Self::square_points(radius)), - LineCap::Custom(points) => Some(Self::custom_points(points.to_vec(), radius)), - }; - - Self { points } - } - - pub(super) fn round_points(angle: P::Scalar, r: P::Scalar) -> Vec

{ - let angle_f64 = angle.to_f64(); - let n = if angle_f64 > 0.0 { - let count = PI / angle_f64; - (count as usize).clamp(2, 1024) - } else { - 1024 - }; - - let fix_angle = P::Scalar::from_float(PI / n as f64); - let rotator = Rotator::with_angle(fix_angle); - let mut v = P::from_xy(P::Scalar::from_float(0.0), P::Scalar::from_float(-1.0)); - let mut points = Vec::with_capacity(n); - for _ in 1..n { - v = rotator.rotate(&v); - let p = FloatPointMath::scale(&v, r); - points.push(p); - } - - points - } - - pub(super) fn square_points(r: P::Scalar) -> Vec

{ - vec![P::from_xy(r, -r), P::from_xy(r, r)] - } - - pub(super) fn custom_points(points: Vec

, r: P::Scalar) -> Vec

{ - let mut scaled = points; - let mut i = 0; - while i < scaled.len() { - let p = &scaled[i]; - scaled[i] = FloatPointMath::scale(p, r); - i += 1 - } - scaled - } - - pub(super) fn add_to_start( - &self, - section: &Section

, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - let mut a = adapter.float_to_int(§ion.a_top); - if let Some(points) = &self.points { - let dir = P::from_xy(-section.dir.x(), -section.dir.y()); - let rotator = Rotator::with_vector(&dir); - for p in points.iter() { - let r = rotator.rotate(p); - let q = FloatPointMath::add(&r, §ion.a); - let b = adapter.float_to_int(&q); - // Repeated template points and rounding can collapse a cap edge. - if a != b { - segments.push(Segment::subject(a, b)); - } - a = b; - } - } - let last = adapter.float_to_int(§ion.a_bot); - if a != last { - segments.push(Segment::subject(a, last)); - } - } - - pub(super) fn add_to_end( - &self, - section: &Section

, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - let mut a = adapter.float_to_int(§ion.b_bot); - if let Some(points) = &self.points { - let rotator = Rotator::with_vector(§ion.dir); - for p in points.iter() { - let r = rotator.rotate(p); - let q = FloatPointMath::add(&r, §ion.b); - let b = adapter.float_to_int(&q); - if a != b { - segments.push(Segment::subject(a, b)); - } - a = b; - } - } - let last = adapter.float_to_int(§ion.b_top); - if a != last { - segments.push(Segment::subject(a, last)); - } - } - - #[inline] - pub(super) fn capacity(&self) -> usize { - if let Some(points) = &self.points { - 1 + points.len() - } else { - 1 - } - } - - #[inline] - pub(super) fn additional_offset(&self) -> P::Scalar { - if let Some(points) = &self.points { - let mut max_sqr_radius = P::Scalar::from_float(0.0); - for point in points { - max_sqr_radius = max_sqr_radius.max(FloatPointMath::sqr_length(point)); - } - max_sqr_radius.sqrt() - } else { - P::Scalar::from_float(0.0) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::fill_rule::FillRule; - use crate::core::overlay::Overlay; - use crate::core::overlay_rule::OverlayRule; - use crate::mesh::float::stroke::section::SectionToSegment; - use i_float::float::rect::FloatRect; - - #[test] - fn round_caps_do_not_emit_collapsed_edges() { - let adapter = FloatPointAdapter::<[f64; 2], i32>::with_scale( - FloatRect { - min_x: -3.0, - max_x: 13.0, - min_y: -3.0, - max_y: 3.0, - }, - 1.0, - ); - let section = Section::new(2.0, &[0.0, 0.0], &[10.0, 0.0]); - let cap = CapBuilder::new(LineCap::Round(0.01 * PI), 2.0); - let mut segments = Vec::new(); - segments.add_section(§ion, &adapter); - cap.add_to_start(§ion, &adapter, &mut segments); - cap.add_to_end(§ion, &adapter, &mut segments); - - let collapsed = segments.iter().filter(|s| s.x_segment.a == s.x_segment.b).count(); - assert_eq!( - collapsed, 0, - "cap construction must skip coincident integer endpoints" - ); - let output = Overlay::with_segments(segments).overlay(OverlayRule::Subject, FillRule::Positive); - assert_eq!(output.len(), 1); - } -} diff --git a/iOverlay/src/mesh/float/stroke/builder_join.rs b/iOverlay/src/mesh/float/stroke/builder_join.rs deleted file mode 100644 index 1ff112c4..00000000 --- a/iOverlay/src/mesh/float/stroke/builder_join.rs +++ /dev/null @@ -1,391 +0,0 @@ -use crate::mesh::float::miter::{Miter, SharpMiter}; -use crate::mesh::float::rotator::Rotator; -use crate::mesh::float::stroke::section::Section; -use crate::segm::boolean::ShapeCountBoolean; -use crate::segm::segment::Segment; -use alloc::vec::Vec; -use core::f64::consts::PI; -use i_float::adapter::FloatPointAdapter; -use i_float::float::compatible::FloatPointCompatible; -use i_float::float::number::FloatNumber; -use i_float::float::vector::FloatPointMath; -use i_float::int::number::int::IntNumber; - -pub(super) trait JoinBuilder { - fn add_join( - &self, - s0: &Section

, - s1: &Section

, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ); - fn capacity(&self) -> usize; - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar; -} - -pub(super) struct BevelJoinBuilder; - -impl BevelJoinBuilder { - #[inline] - fn join_top( - s0: &Section

, - s1: &Section

, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - Self::add_segment(&s0.b_top, &s1.a_top, adapter, segments); - } - - #[inline] - fn join_bot( - s0: &Section

, - s1: &Section

, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - Self::add_segment(&s1.a_bot, &s0.b_bot, adapter, segments); - } - - #[inline] - fn add_segment( - a: &P, - b: &P, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - let ia = adapter.float_to_int(a); - let ib = adapter.float_to_int(b); - if ia != ib { - segments.push(Segment::subject(ib, ia)); - } - } -} - -impl JoinBuilder for BevelJoinBuilder { - #[inline] - fn add_join( - &self, - s0: &Section

, - s1: &Section

, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - Self::join_top(s0, s1, adapter, segments); - Self::join_bot(s0, s1, adapter, segments); - } - - #[inline] - fn capacity(&self) -> usize { - 2 - } - - #[inline] - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar { - // add extra 10% to avoid problems with floating point precision. - P::Scalar::from_float(1.1) * radius - } -} - -pub(super) struct MiterJoinBuilder { - limit_dot_product: T, - max_offset: T, - max_length: T, -} - -impl MiterJoinBuilder { - pub(super) fn new(angle: T, radius: T) -> Self { - // angle - min possible angle - let fixed_angle = angle.max(T::from_float(0.01)); - let limit_dot_product = -fixed_angle.cos(); - - let half_angle = T::from_float(0.5) * fixed_angle; - let tan = half_angle.tan(); - - let r = radius; - let max_length = r / tan; - let sqr_len = max_length * max_length; - let sqr_rad = r * r; - // add extra 10% to avoid problems with floating point precision. - let extra_scale = T::from_float(1.1); - - let max_offset = extra_scale * (sqr_rad + sqr_len).sqrt(); - - Self { - limit_dot_product, - max_offset, - max_length, - } - } -} - -impl JoinBuilder for MiterJoinBuilder { - fn add_join( - &self, - s0: &Section

, - s1: &Section

, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - let cross_product = FloatPointMath::cross_product(&s0.dir, &s1.dir); - if cross_product.abs() < P::Scalar::from_float(0.0001) { - BevelJoinBuilder::join_top(s0, s1, adapter, segments); - BevelJoinBuilder::join_bot(s0, s1, adapter, segments); - return; - } - - let turn = cross_product > P::Scalar::from_float(0.0); - - let dot_product = FloatPointMath::dot_product(&s0.dir, &s1.dir); - - let is_limited = self.limit_dot_product > dot_product; - - if is_limited { - let (pa, pb, ac, bc) = if turn { - BevelJoinBuilder::join_top(s0, s1, adapter, segments); - let (pa, pb, va, vb) = (s1.a_bot, s0.b_bot, s1.dir, s0.dir); - - let ax = pa.x() - self.max_length * va.x(); - let ay = pa.y() - self.max_length * va.y(); - let bx = pb.x() + self.max_length * vb.x(); - let by = pb.y() + self.max_length * vb.y(); - - let ac = P::from_xy(ax, ay); - let bc = P::from_xy(bx, by); - - (pa, pb, ac, bc) - } else { - BevelJoinBuilder::join_bot(s0, s1, adapter, segments); - let (pa, pb, va, vb) = (s0.b_top, s1.a_top, s0.dir, s1.dir); - - let ax = pa.x() + self.max_length * va.x(); - let ay = pa.y() + self.max_length * va.y(); - let bx = pb.x() - self.max_length * vb.x(); - let by = pb.y() - self.max_length * vb.y(); - - let ac = P::from_xy(ax, ay); - let bc = P::from_xy(bx, by); - - (pa, pb, ac, bc) - }; - - let ia = adapter.float_to_int(&pa); - let ib = adapter.float_to_int(&pb); - - if ia == ib { - return; - } - - let iac = adapter.float_to_int(&ac); - let ibc = adapter.float_to_int(&bc); - - if ia != iac { - segments.push(Segment::subject(iac, ia)); - } - if iac != ibc { - segments.push(Segment::subject(ibc, iac)); - } - if ibc != ib { - segments.push(Segment::subject(ib, ibc)); - } - } else { - let (pa, pb, va, vb) = if turn { - BevelJoinBuilder::join_top(s0, s1, adapter, segments); - (s1.a_bot, s0.b_bot, s1.dir, s0.dir) - } else { - BevelJoinBuilder::join_bot(s0, s1, adapter, segments); - (s0.b_top, s1.a_top, s0.dir, s1.dir) - }; - match Miter::sharp(pa, pb, va, vb, adapter) { - SharpMiter::AB(a, b) => segments.push(Segment::subject(b, a)), - SharpMiter::AcB(a, c, b) => { - segments.push(Segment::subject(c, a)); - segments.push(Segment::subject(b, c)); - } - SharpMiter::Degenerate => {} - } - } - } - - #[inline] - fn capacity(&self) -> usize { - 4 - } - - #[inline] - fn additional_offset(&self, _radius: P::Scalar) -> P::Scalar { - self.max_offset - } -} - -pub(super) struct RoundJoinBuilder { - inv_ratio: T, - average_count: usize, - radius: T, - limit_dot_product: T, -} - -impl RoundJoinBuilder { - pub(super) fn new(ratio: T, radius: T) -> Self { - // ratio = A / R - let fixed_ratio = ratio.min(T::from_float(0.25 * PI)); - let limit_dot_product = fixed_ratio.cos(); - let average_count = (T::from_float(0.6 * PI) / fixed_ratio).to_usize() + 2; - Self { - inv_ratio: T::from_float(1.0) / fixed_ratio, - average_count, - radius, - limit_dot_product, - } - } -} -impl JoinBuilder for RoundJoinBuilder { - fn add_join( - &self, - s0: &Section

, - s1: &Section

, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) { - let dot_product = FloatPointMath::dot_product(&s0.dir, &s1.dir); - if self.limit_dot_product < dot_product { - BevelJoinBuilder::join_top(s0, s1, adapter, segments); - BevelJoinBuilder::join_bot(s0, s1, adapter, segments); - return; - } - - // Normalization can round the dot product outside [-1, 1], making acos return NaN. - let angle = dot_product.max(-P::Scalar::ONE).min(P::Scalar::ONE).acos(); - let n = (angle * self.inv_ratio).to_usize(); - let delta_angle = angle / P::Scalar::from_usize(n); - - let cross_product = FloatPointMath::cross_product(&s0.dir, &s1.dir); - let (start, end, dir) = if cross_product > P::Scalar::from_float(0.0) { - BevelJoinBuilder::join_top(s0, s1, adapter, segments); - let ortho = P::from_xy(s1.dir.y(), -s1.dir.x()); - (s1.a_bot, s0.b_bot, ortho) - } else { - BevelJoinBuilder::join_bot(s0, s1, adapter, segments); - let ortho = P::from_xy(-s0.dir.y(), s0.dir.x()); - (s0.b_top, s1.a_top, ortho) - }; - let rotator = Rotator::::with_angle(-delta_angle); - - let center = s0.b; - let mut v = dir; - let mut a = adapter.float_to_int(&start); - for _ in 1..n { - v = rotator.rotate(&v); - let p = FloatPointMath::add(¢er, &FloatPointMath::scale(&v, self.radius)); - - let b = adapter.float_to_int(&p); - if a != b { - segments.push(Segment::subject(b, a)); - a = b; - } - } - - let b = adapter.float_to_int(&end); - if a != b { - segments.push(Segment::subject(b, a)); - } - } - - #[inline] - fn capacity(&self) -> usize { - self.average_count - } - - #[inline] - fn additional_offset(&self, radius: P::Scalar) -> P::Scalar { - // add extra 10% to avoid problems with floating point precision. - P::Scalar::from_float(1.1) * radius - } -} - -#[cfg(test)] -mod tests { - use super::{BevelJoinBuilder, JoinBuilder, MiterJoinBuilder, RoundJoinBuilder}; - use crate::mesh::float::stroke::section::Section; - use crate::segm::boolean::ShapeCountBoolean; - use crate::segm::segment::Segment; - use alloc::vec::Vec; - use core::f64::consts::PI; - use i_float::adapter::FloatPointAdapter; - use i_float::float::rect::FloatRect; - - type TestSegment = Segment; - - fn build_join>( - builder: &J, - radius: f64, - a: [f64; 2], - b: [f64; 2], - c: [f64; 2], - scale: f64, - ) -> Vec { - let rect = FloatRect::new(-20.0, 20.0, -20.0, 20.0); - let adapter = FloatPointAdapter::try_with_scale(rect, scale).unwrap(); - let s0 = Section::new(radius, &a, &b); - let s1 = Section::new(radius, &b, &c); - let mut segments = Vec::new(); - - builder.add_join(&s0, &s1, &adapter, &mut segments); - - segments - } - - #[test] - fn acute_angle_uses_each_requested_join_type() { - let radius = 1.0; - let a = [-10.0, 0.0]; - let b = [0.0, 0.0]; - let c = [-10.0, 0.1]; - let scale = 1_000.0; - - let bevel = build_join(&BevelJoinBuilder, radius, a, b, c, scale); - let miter = build_join(&MiterJoinBuilder::new(PI / 6.0, radius), radius, a, b, c, scale); - let round = build_join(&RoundJoinBuilder::new(PI / 12.0, radius), radius, a, b, c, scale); - - assert_eq!(bevel.len(), 2); - assert_eq!(miter.len(), 4); - assert!(round.len() > miter.len()); - assert_ne!(miter, bevel); - assert_ne!(round, bevel); - } - - #[test] - fn near_collinear_segments_fall_back_to_stable_bevel_join() { - let radius = 1.0; - let a = [-10.0, 0.0]; - let b = [0.0, 0.0]; - let c = [10.0, 0.000_01]; - let scale = 1_000_000.0; - - let bevel = build_join(&BevelJoinBuilder, radius, a, b, c, scale); - let miter = build_join(&MiterJoinBuilder::new(PI / 6.0, radius), radius, a, b, c, scale); - let round = build_join(&RoundJoinBuilder::new(PI / 12.0, radius), radius, a, b, c, scale); - - assert_eq!(miter, bevel); - assert_eq!(round, bevel); - - let repeated = build_join(&MiterJoinBuilder::new(PI / 6.0, radius), radius, a, b, c, scale); - assert_eq!(repeated, miter); - } - - #[test] - fn tiny_offset_does_not_create_degenerate_join_segments() { - let radius = 0.01; - let a = [-10.0, 0.0]; - let b = [0.0, 0.0]; - let c = [0.0, 10.0]; - let scale = 10.0; - - let bevel = build_join(&BevelJoinBuilder, radius, a, b, c, scale); - let miter = build_join(&MiterJoinBuilder::new(PI / 6.0, radius), radius, a, b, c, scale); - let round = build_join(&RoundJoinBuilder::new(PI / 12.0, radius), radius, a, b, c, scale); - - assert!(bevel.is_empty()); - assert!(miter.is_empty()); - assert!(round.is_empty()); - } -} diff --git a/iOverlay/src/mesh/float/stroke/mod.rs b/iOverlay/src/mesh/float/stroke/mod.rs index 4d1e1384..01590761 100644 --- a/iOverlay/src/mesh/float/stroke/mod.rs +++ b/iOverlay/src/mesh/float/stroke/mod.rs @@ -1,5 +1 @@ -mod builder; -mod builder_cap; -mod builder_join; pub mod offset; -mod section; diff --git a/iOverlay/src/mesh/float/stroke/offset.rs b/iOverlay/src/mesh/float/stroke/offset.rs index 11e80dd8..32c86213 100644 --- a/iOverlay/src/mesh/float/stroke/offset.rs +++ b/iOverlay/src/mesh/float/stroke/offset.rs @@ -1,12 +1,8 @@ -use crate::core::fill_rule::FillRule; use crate::core::integer::OverlayInt; -use crate::core::overlay::Overlay; -use crate::core::overlay_rule::OverlayRule; use crate::float::overlay::OverlayOptions; use crate::float::scale::FixedScaleOverlayError; -use crate::mesh::float::stroke::builder::StrokeBuilder; -use crate::mesh::float::stroke::offset::vec::Vec; use crate::mesh::float::style::StrokeStyle; +use crate::mesh::int::stroke::offset::IntStrokeOffset; use alloc::vec; use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; @@ -18,7 +14,7 @@ use i_float::int::number::wide_int::WideIntNumber; use i_shape::base::data::Shapes; use i_shape::flat::buffer::FlatContoursBuffer; use i_shape::flat::float::FloatFlatContoursBuffer; -use i_shape::float::adapter::ShapesToFloat; +use i_shape::float::adapter::{ResourceToInt, ShapesToFloat}; use i_shape::float::despike::DeSpikeContour; use i_shape::float::simple::SimplifyContour; use i_shape::source::float::resource::ShapeResource; @@ -259,25 +255,6 @@ where self.stroke_custom_into(style, is_closed_path, Default::default(), output) } - fn stroke_fixed_scale( - &self, - style: StrokeStyle

, - is_closed_path: bool, - scale: P::Scalar, - ) -> Result, FixedScaleOverlayError> { - self.stroke_custom_fixed_scale(style, is_closed_path, Default::default(), scale) - } - - fn stroke_fixed_scale_into( - &self, - style: StrokeStyle

, - is_closed_path: bool, - scale: P::Scalar, - output: &mut FloatFlatContoursBuffer

, - ) -> Result<(), FixedScaleOverlayError> { - self.stroke_custom_fixed_scale_into(style, is_closed_path, Default::default(), scale, output) - } - fn stroke_custom( &self, style: StrokeStyle

, @@ -303,6 +280,25 @@ where } } + fn stroke_fixed_scale( + &self, + style: StrokeStyle

, + is_closed_path: bool, + scale: P::Scalar, + ) -> Result, FixedScaleOverlayError> { + self.stroke_custom_fixed_scale(style, is_closed_path, Default::default(), scale) + } + + fn stroke_fixed_scale_into( + &self, + style: StrokeStyle

, + is_closed_path: bool, + scale: P::Scalar, + output: &mut FloatFlatContoursBuffer

, + ) -> Result<(), FixedScaleOverlayError> { + self.stroke_custom_fixed_scale_into(style, is_closed_path, Default::default(), scale, output) + } + fn stroke_custom_fixed_scale( &self, style: StrokeStyle

, @@ -455,10 +451,8 @@ where struct StrokeSolver { r: P::Scalar, - builder: StrokeBuilder, + style: StrokeStyle

, adapter: FloatPointAdapter, - paths_count: usize, - points_count: usize, } impl StrokeSolver @@ -467,32 +461,18 @@ where I: OverlayInt + 'static, { fn prepare>(source: &S, style: StrokeStyle

) -> Option { - let mut paths_count = 0; - let mut points_count = 0; - for path in source.iter_paths() { - paths_count += 1; - points_count += path.len(); - } - - if paths_count == 0 { + if source.iter_paths().next().is_none() { return None; } let r = P::Scalar::from_float(0.5 * style.width.to_f64()); - let builder = StrokeBuilder::::new(style); - let a = builder.additional_offset(r); + let a = style.padding(); let mut rect = FloatRect::with_iter(source.iter_paths().flatten()).unwrap_or(FloatRect::zero()); rect.add_offset(a); let adapter = FloatPointAdapter::::new(rect); - Some(Self { - r, - builder, - adapter, - paths_count, - points_count, - }) + Some(Self { r, style, adapter }) } fn apply_scale(&mut self, scale: P::Scalar) -> Result<(), FixedScaleOverlayError> { @@ -512,20 +492,11 @@ where return vec![]; } - let capacity = self - .builder - .capacity(self.paths_count, self.points_count, is_closed_path); - let mut segments = Vec::with_capacity(capacity); - - for path in source.iter_paths() { - self.builder - .build(path, is_closed_path, &self.adapter, &mut segments); - } - - let mut overlay = Overlay::with_segments(segments); - overlay.options = options.int_with_adapter(&self.adapter); - - let shapes = overlay.overlay(OverlayRule::Subject, FillRule::Positive); + let paths = source.to_int_paths(&self.adapter); + let style = self.style.to_int(&self.adapter); + let shapes = paths + .stroke_custom(&style, is_closed_path, options.int_with_adapter(&self.adapter)) + .expect("valid integer stroke"); let mut float = shapes.to_float(&self.adapter); @@ -554,21 +525,17 @@ where return; } - let capacity = self - .builder - .capacity(self.paths_count, self.points_count, is_closed_path); - let mut segments = Vec::with_capacity(capacity); - - for path in source.iter_paths() { - self.builder - .build(path, is_closed_path, &self.adapter, &mut segments); - } - - let mut overlay = Overlay::with_segments(segments); - overlay.options = options.int_with_adapter(&self.adapter); - + let paths = source.to_int_paths(&self.adapter); + let style = self.style.to_int(&self.adapter); let mut int_output = FlatContoursBuffer::::with_capacity(0); - overlay.overlay_into(OverlayRule::Subject, FillRule::Positive, &mut int_output); + paths + .stroke_custom_into( + &style, + is_closed_path, + options.int_with_adapter(&self.adapter), + &mut int_output, + ) + .expect("valid integer stroke"); let iter = int_output.points.iter().map(|p| self.adapter.int_to_float(p)); output.set_with_iter(iter, &int_output.ranges); diff --git a/iOverlay/src/mesh/float/stroke/section.rs b/iOverlay/src/mesh/float/stroke/section.rs deleted file mode 100644 index 321d02ca..00000000 --- a/iOverlay/src/mesh/float/stroke/section.rs +++ /dev/null @@ -1,62 +0,0 @@ -use crate::mesh::float::math::Math; -use crate::segm::boolean::ShapeCountBoolean; -use crate::segm::segment::Segment; -use alloc::vec::Vec; -use i_float::adapter::FloatPointAdapter; -use i_float::float::compatible::FloatPointCompatible; -use i_float::float::vector::FloatPointMath; -use i_float::int::number::int::IntNumber; - -#[derive(Clone)] -pub(super) struct Section { - pub(super) a: P, - pub(super) b: P, - pub(super) a_top: P, - pub(super) b_top: P, - pub(super) a_bot: P, - pub(super) b_bot: P, - pub(super) dir: P, -} - -impl Section

{ - pub(crate) fn new(radius: P::Scalar, a: &P, b: &P) -> Self { - let dir = Math::normal(b, a); - let t = Math::ortho_and_scale(&dir, radius); - - let a_top = FloatPointMath::add(a, &t); - let a_bot = FloatPointMath::sub(a, &t); - - let b_top = FloatPointMath::add(b, &t); - let b_bot = FloatPointMath::sub(b, &t); - - Section { - a: *a, - b: *b, - a_top, - b_top, - a_bot, - b_bot, - dir, - } - } -} - -pub(crate) trait SectionToSegment { - fn add_section(&mut self, section: &Section

, adapter: &FloatPointAdapter); -} - -impl SectionToSegment for Vec> { - fn add_section(&mut self, section: &Section

, adapter: &FloatPointAdapter) { - let a_top = adapter.float_to_int(§ion.a_top); - let b_top = adapter.float_to_int(§ion.b_top); - let a_bot = adapter.float_to_int(§ion.a_bot); - let b_bot = adapter.float_to_int(§ion.b_bot); - - if a_top != b_top { - self.push(Segment::subject(b_top, a_top)); - } - if a_bot != b_bot { - self.push(Segment::subject(a_bot, b_bot)); - } - } -} diff --git a/iOverlay/src/mesh/float/style.rs b/iOverlay/src/mesh/float/style.rs index 5d0d4c6e..d55fb15b 100644 --- a/iOverlay/src/mesh/float/style.rs +++ b/iOverlay/src/mesh/float/style.rs @@ -1,7 +1,14 @@ +use crate::mesh::int::arc::ArcOptions; +use crate::mesh::int::style::{IntLineCap, IntLineJoin, IntStrokeStyle}; use alloc::rc::Rc; +use alloc::vec::Vec; use core::f64::consts::PI; +use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; use i_float::float::number::FloatNumber; +use i_float::int::angle::Angle; +use i_float::int::number::int::IntNumber; +use i_float::int::point::IntPoint; /// The endpoint style of a line. #[derive(Debug, Clone)] @@ -64,7 +71,32 @@ impl LineCap

{ } } +impl From<&LineJoin> for IntLineJoin { + /// Converts a floating-point join, normalizing its angle before quantization. + fn from(join: &LineJoin) -> Self { + match join.clone().normalize() { + LineJoin::Bevel => IntLineJoin::Bevel, + LineJoin::Miter(a) => IntLineJoin::Miter(Angle::from_radians(a)), + LineJoin::Round(a) => IntLineJoin::Round(ArcOptions { + max_step: Angle::from_radians(a), + ..ArcOptions::default() + }), + } + } +} + impl LineJoin { + /// Conservative multiplier for the join's reach relative to the offset radius. + pub(super) fn padding(&self) -> f64 { + match IntLineJoin::from(self) { + IntLineJoin::Miter(minimum) => { + let sin = Angle::from_bits(minimum.bits() / 2).sin() as f64; + 1.1 * (1u32 << 30) as f64 / sin + } + _ => 1.1, + } + } + pub(crate) fn normalize(self) -> Self { match self { LineJoin::Miter(ratio) => { @@ -112,6 +144,64 @@ impl StrokeStyle

{ self.join = join.normalize(); self } + + pub(super) fn to_int(&self, adapter: &FloatPointAdapter) -> IntStrokeStyle { + let radius = P::Scalar::from_float(0.5 * self.width.to_f64().max(0.0)); + let cap = |cap: &LineCap

| match cap.clone().normalize() { + LineCap::Butt => IntLineCap::Butt, + LineCap::Square => IntLineCap::Square, + LineCap::Round(a) => IntLineCap::Round(ArcOptions { + max_step: Angle::from_radians(a), + ..ArcOptions::default() + }), + LineCap::Custom(points) => IntLineCap::Custom( + points + .iter() + .map(|p| { + IntPoint::new( + adapter.round_len_to_int(p.x() * radius), + adapter.round_len_to_int(p.y() * radius), + ) + }) + .collect::>() + .into(), + ), + }; + let radius = adapter.round_len_to_int(radius); + IntStrokeStyle { + width: I::from_wide(radius.to_wide() + radius.to_wide()), + start_cap: cap(&self.start_cap), + end_cap: cap(&self.end_cap), + join: IntLineJoin::from(&self.join), + } + } + + /// Conservative distance by which the stroke can extend beyond its input bounds. + pub(super) fn padding(&self) -> P::Scalar { + let cap = |cap: &LineCap

| match cap { + LineCap::Square => 2.0, + LineCap::Custom(points) => { + points + .iter() + .map(|p| { + let x = p.x().to_f64(); + let y = p.y().to_f64(); + FloatNumber::sqrt(x * x + y * y) + }) + .fold(1.0_f64, f64::max) + * 1.1 + } + _ => 1.1, + }; + P::Scalar::from_float( + 0.5 * self.width.to_f64().max(0.0) + * self + .join + .padding() + .max(cap(&self.start_cap)) + .max(cap(&self.end_cap)), + ) + } } impl Default for StrokeStyle

{ diff --git a/iOverlay/src/mesh/float/variable_stroke/builder.rs b/iOverlay/src/mesh/float/variable_stroke/builder.rs deleted file mode 100644 index 9fd16b13..00000000 --- a/iOverlay/src/mesh/float/variable_stroke/builder.rs +++ /dev/null @@ -1,1084 +0,0 @@ -use crate::mesh::float::rotator::Rotator; -use crate::mesh::float::variable_stroke::section::{RadiusTrend, Section}; -use crate::mesh::float::variable_stroke::style::{StrokeVertex, VariableStrokeStyle}; -use crate::segm::boolean::ShapeCountBoolean; -use crate::segm::segment::Segment; -use alloc::vec::Vec; -use core::f64::consts::PI; -use i_float::adapter::FloatPointAdapter; -use i_float::float::compatible::FloatPointCompatible; -use i_float::float::number::FloatNumber; -use i_float::float::vector::FloatPointMath; -use i_float::int::number::int::IntNumber; -use i_float::int::number::uint::UIntNumber; -use i_float::int::number::wide_int::WideIntNumber; - -#[cfg(feature = "variable_stroke_debug")] -use crate::mesh::float::variable_stroke::{VariableStrokeDebugEdge, VariableStrokeDebugEdgeKind}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Cap { - Butt, - Round, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ArcSweep { - Minor, - Major, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct SubSegment { - start: usize, - end: usize, - start_cap: Cap, - end_cap: Cap, -} - -pub(super) struct VariableStrokeBuilder { - round_angle: T, -} - -impl VariableStrokeBuilder { - pub(super) fn new(style: VariableStrokeStyle) -> Self { - Self { - round_angle: style.normalized().round_angle, - } - } - - pub(super) fn build( - &self, - path: &[StrokeVertex

], - adapter: &FloatPointAdapter, - segments: &mut Vec>, - ) where - P: FloatPointCompatible, - I: IntNumber, - { - if path.is_empty() { - return; - } - - let subsegments = Self::find_subsegments(path, adapter); - let mut output = SegmentBuilder { - adapter, - segments, - #[cfg(feature = "variable_stroke_debug")] - debug_edges: None, - #[cfg(feature = "variable_stroke_debug")] - debug_path_index: 0, - }; - - for subsegment in subsegments.iter() { - self.add_subsegment(subsegment, path, &mut output); - } - } - - #[cfg(feature = "variable_stroke_debug")] - pub(super) fn build_debug( - &self, - path: &[StrokeVertex

], - path_index: usize, - adapter: &FloatPointAdapter, - segments: &mut Vec>, - debug_edges: &mut Vec>, - ) where - P: FloatPointCompatible, - I: IntNumber, - { - if path.is_empty() { - return; - } - - let subsegments = Self::find_subsegments(path, adapter); - let mut output = SegmentBuilder { - adapter, - segments, - debug_edges: Some(debug_edges), - debug_path_index: path_index, - }; - - for subsegment in subsegments.iter() { - self.add_subsegment(subsegment, path, &mut output); - } - } - - fn add_subsegment( - &self, - subsegment: &SubSegment, - path: &[StrokeVertex

], - output: &mut SegmentBuilder, - ) where - P: FloatPointCompatible, - I: IntNumber, - { - if subsegment.start == subsegment.end { - if subsegment.start_cap != Cap::Butt || subsegment.end_cap != Cap::Butt { - let vertex = &path[subsegment.start]; - output.add_circle(&vertex.point, vertex.radius(), self.round_angle); - } - return; - } - - let adapter = output.adapter; - let mut sections = (subsegment.start..subsegment.end) - .filter_map(|index| Section::try_new(&path[index], &path[index + 1], adapter)); - let Some(mut previous) = sections.next() else { - return; - }; - - output.add_section(&previous); - output.add_start_cap(&previous, subsegment.start_cap, self.round_angle); - - for section in sections { - output.add_section(§ion); - output.add_join(&previous, §ion, self.round_angle); - previous = section; - } - - output.add_end_cap(&previous, subsegment.end_cap, self.round_angle); - } - - fn find_subsegments(path: &[StrokeVertex

], adapter: &FloatPointAdapter) -> Vec - where - P: FloatPointCompatible, - I: IntNumber, - { - if path.is_empty() { - return Vec::new(); - } - - let mut result = Vec::new(); - let mut start = 0; - let mut start_cap = Cap::Round; - let mut final_end_cap = Cap::Round; - - for (index, pair) in path.windows(2).enumerate() { - final_end_cap = Cap::Round; - - if let Some((end_cap, next_start_cap)) = Self::break_caps(&pair[0], &pair[1], adapter) { - result.push(SubSegment { - start, - end: index, - start_cap, - end_cap, - }); - - start = index + 1; - start_cap = next_start_cap; - continue; - } - - if index > 0 && Self::circle_is_covered_by_section(&path[index - 1], &pair[0], &pair[1], adapter) - { - result.push(SubSegment { - start, - end: index, - start_cap, - end_cap: Cap::Round, - }); - - start = index; - start_cap = Cap::Butt; - final_end_cap = Cap::Butt; - } - } - - result.push(SubSegment { - start, - end: path.len() - 1, - start_cap, - end_cap: final_end_cap, - }); - result - } - - fn break_caps( - a: &StrokeVertex

, - b: &StrokeVertex

, - adapter: &FloatPointAdapter, - ) -> Option<(Cap, Cap)> - where - P: FloatPointCompatible, - I: IntNumber, - { - let int_a = adapter.float_to_int(&a.point); - let int_b = adapter.float_to_int(&b.point); - let a_radius = adapter.round_len_to_int(a.radius()); - let b_radius = adapter.round_len_to_int(b.radius()); - let radius_delta = a_radius.to_wide() - b_radius.to_wide(); - let distance_sqr = (int_b - int_a).sqr_length(); - - if (radius_delta * radius_delta).to_uint() < distance_sqr { - return None; - } - - if a_radius >= b_radius { - Some((Cap::Round, Cap::Butt)) - } else { - Some((Cap::Butt, Cap::Round)) - } - } - - fn circle_is_covered_by_section( - a: &StrokeVertex

, - b: &StrokeVertex

, - c: &StrokeVertex

, - adapter: &FloatPointAdapter, - ) -> bool - where - P: FloatPointCompatible, - I: IntNumber, - { - let a_radius = adapter.round_len_to_int(a.radius()); - let b_radius = adapter.round_len_to_int(b.radius()); - let c_radius = adapter.round_len_to_int(c.radius()); - if a_radius.max(b_radius) <= c_radius { - return false; - } - - let Some(section) = Section::try_new(a, b, adapter) else { - return false; - }; - - let points = [ - adapter.float_to_int(§ion.a_left), - adapter.float_to_int(§ion.b_left), - adapter.float_to_int(§ion.b_right), - adapter.float_to_int(§ion.a_right), - ]; - let center = adapter.float_to_int(&c.point); - let radius = c_radius.to_wide().to_uint(); - let first_edge = points[1] - points[0]; - let orientation = first_edge.cross_product(points[2] - points[1]); - if orientation == I::Wide::ZERO { - return false; - } - - for index in 0..points.len() { - let a = points[index]; - let b = points[(index + 1) % points.len()]; - let edge = b - a; - let side = edge.cross_product(center - a); - let interior_distance = if orientation > I::Wide::ZERO { side } else { -side }; - if interior_distance < I::Wide::ZERO { - return false; - } - - let length_sqr = edge.sqr_length(); - let mut length = length_sqr.isqrt(); - if length * length < length_sqr { - length += I::WideUInt::ONE; - } - if interior_distance.to_uint() < radius * length { - return false; - } - } - - true - } - - pub(super) fn capacity(&self, paths_count: usize, points_count: usize) -> usize { - let edge_count = points_count.saturating_sub(paths_count); - let round_count = (T::from_float(2.0 * PI) / self.round_angle) - .to_usize() - .saturating_add(1); - 2 * edge_count + 2 * round_count * points_count - } - - pub(super) fn additional_offset(&self, max_radius: T) -> T { - T::from_float(1.1) * max_radius - } -} - -struct SegmentBuilder<'a, P: FloatPointCompatible, I: IntNumber> { - adapter: &'a FloatPointAdapter, - segments: &'a mut Vec>, - #[cfg(feature = "variable_stroke_debug")] - debug_edges: Option<&'a mut Vec>>, - #[cfg(feature = "variable_stroke_debug")] - debug_path_index: usize, -} - -impl SegmentBuilder<'_, P, I> { - fn add_circle(&mut self, center: &P, radius: P::Scalar, angle: P::Scalar) { - let int_radius = self.adapter.round_len_to_int(radius); - if int_radius <= I::ONE { - return; - } - - let center = self.adapter.int_to_float(&self.adapter.float_to_int(center)); - let radius = self.adapter.len_to_float(int_radius); - let count = (P::Scalar::from_float(2.0 * PI) / angle) - .to_usize() - .saturating_add(1) - .clamp(3, 1024); - let rotator = Rotator::with_angle(P::Scalar::from_float(2.0 * PI) / P::Scalar::from_usize(count)); - let mut vector = P::from_xy(radius, P::Scalar::ZERO); - let first = FloatPointMath::add(¢er, &vector); - let mut a = first; - - for i in 1..=count { - let b = if i == count { - first - } else { - vector = rotator.rotate(&vector); - FloatPointMath::add(¢er, &vector) - }; - self.add_edge( - &a, - &b, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::CircleArc, - ); - a = b; - } - } - - #[inline] - fn add_section(&mut self, section: &Section

) { - self.add_edge( - §ion.b_left, - §ion.a_left, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::SectionBoundary, - ); - self.add_edge( - §ion.a_right, - §ion.b_right, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::SectionBoundary, - ); - } - - fn add_join(&mut self, prev: &Section

, next: &Section

, angle: P::Scalar) -> usize { - let prev_center = self.adapter.float_to_int(&prev.b); - let next_center = self.adapter.float_to_int(&next.a); - if prev_center != next_center { - // A non-drawable section between these sections was filtered out. They belong to - // separate chains, so close both chains instead of building an arc between centers. - self.add_end_cap(prev, Cap::Butt, angle); - self.add_start_cap(next, Cap::Butt, angle); - return 0; - } - - let prev_a_left = self.adapter.float_to_int(&prev.a_left); - let prev_b_left = self.adapter.float_to_int(&prev.b_left); - let prev_a_right = self.adapter.float_to_int(&prev.a_right); - let prev_b_right = self.adapter.float_to_int(&prev.b_right); - let next_a_left = self.adapter.float_to_int(&next.a_left); - let next_b_left = self.adapter.float_to_int(&next.b_left); - let next_a_right = self.adapter.float_to_int(&next.a_right); - let next_b_right = self.adapter.float_to_int(&next.b_right); - - let prev_left = prev_b_left - prev_a_left; - let prev_right = prev_b_right - prev_a_right; - let next_left = next_b_left - next_a_left; - let next_right = next_b_right - next_a_right; - - let mut arc_count = 0; - let left_cross = next_left.cross_product(prev_left); - - let right_cross = prev_right.cross_product(next_right); - - let prev_a = self.adapter.float_to_int(&prev.a); - let prev_b = prev_center; - let next_a = self.adapter.float_to_int(&next.a); - let next_b = self.adapter.float_to_int(&next.b); - - let prev_middle = prev_b - prev_a; - let next_middle = next_b - next_a; - - let middle_cross = prev_middle.cross_product(next_middle); - - let left_arc = left_cross > I::Wide::ZERO || middle_cross < I::Wide::ZERO; - let right_arc = right_cross > I::Wide::ZERO || middle_cross >= I::Wide::ZERO; - - if left_arc { - arc_count += self.add_arc_ccw( - &prev.b, - &next.a_left, - &prev.b_left, - angle, - ArcSweep::Minor, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::JoinArc, - ) as usize; - } else { - self.add_edge( - &next.a_left, - &prev.b_left, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::JoinClosure, - ); - } - - if right_arc { - arc_count += self.add_arc_ccw( - &prev.b, - &prev.b_right, - &next.a_right, - angle, - ArcSweep::Major, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::JoinArc, - ) as usize; - } else { - self.add_edge( - &prev.b_right, - &next.a_right, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::JoinClosure, - ); - } - - arc_count - } - - fn add_start_cap(&mut self, section: &Section

, cap: Cap, angle: P::Scalar) { - match cap { - Cap::Butt => self.add_edge( - §ion.a_left, - §ion.a_right, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::CapClosure, - ), - Cap::Round => { - let sweep = if section.radius_trend == RadiusTrend::Decreasing { - ArcSweep::Major - } else { - ArcSweep::Minor - }; - self.add_arc_ccw( - §ion.a, - §ion.a_left, - §ion.a_right, - angle, - sweep, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::CapArc, - ); - } - } - } - - fn add_end_cap(&mut self, section: &Section

, cap: Cap, angle: P::Scalar) { - match cap { - Cap::Butt => self.add_edge( - §ion.b_right, - §ion.b_left, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::CapClosure, - ), - Cap::Round => { - let sweep = if section.radius_trend == RadiusTrend::Increasing { - ArcSweep::Major - } else { - ArcSweep::Minor - }; - self.add_arc_ccw( - §ion.b, - §ion.b_right, - §ion.b_left, - angle, - sweep, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::CapArc, - ); - } - } - } - - fn arc_sweep_ccw(&self, center: &P, from: &P, to: &P, aligned_sweep: ArcSweep) -> ArcSweep { - let center = self.adapter.float_to_int(center); - let from_vector = self.adapter.float_to_int(from) - center; - let to_vector = self.adapter.float_to_int(to) - center; - let cross = from_vector.cross_product(to_vector); - - if cross > I::Wide::ZERO { - ArcSweep::Minor - } else if cross < I::Wide::ZERO { - ArcSweep::Major - } else if from_vector.dot_product(to_vector) < I::Wide::ZERO { - // Both choices describe the same half-circle. - ArcSweep::Minor - } else { - // Coincident directions can mean either a collapsed minor arc or a full major arc. - aligned_sweep - } - } - - fn add_arc_ccw( - &mut self, - center: &P, - from: &P, - to: &P, - angle: P::Scalar, - aligned_sweep: ArcSweep, - #[cfg(feature = "variable_stroke_debug")] edge_kind: VariableStrokeDebugEdgeKind, - ) -> bool { - let sweep = self.arc_sweep_ccw(center, from, to, aligned_sweep); - if sweep == ArcSweep::Minor && self.adapter.float_to_int(from) == self.adapter.float_to_int(to) { - return false; - } - - let from_point = *from; - let to_point = *to; - let from_vector = FloatPointMath::sub(&from_point, center); - let from_unit = FloatPointMath::normalize(&from_vector); - let to_unit = FloatPointMath::normalize(&FloatPointMath::sub(&to_point, center)); - let dot = FloatPointMath::dot_product(&from_unit, &to_unit) - .max(-P::Scalar::ONE) - .min(P::Scalar::ONE); - let base = dot.acos(); - let sweep = match sweep { - ArcSweep::Minor => base, - ArcSweep::Major => P::Scalar::from_float(2.0 * PI) - base, - }; - let count = (sweep / angle).to_usize().saturating_add(1).clamp(1, 1024); - let rotator = Rotator::with_angle(sweep / P::Scalar::from_usize(count)); - - let mut vector = from_vector; - let mut a = from_point; - for i in 1..=count { - let b = if i == count { - to_point - } else { - vector = rotator.rotate(&vector); - FloatPointMath::add(center, &vector) - }; - #[cfg(not(feature = "variable_stroke_debug"))] - self.add_edge(&a, &b); - #[cfg(feature = "variable_stroke_debug")] - self.add_edge(&a, &b, edge_kind); - a = b; - } - - true - } - - #[inline] - fn add_edge( - &mut self, - a: &P, - b: &P, - #[cfg(feature = "variable_stroke_debug")] kind: VariableStrokeDebugEdgeKind, - ) { - let a = self.adapter.float_to_int(a); - let b = self.adapter.float_to_int(b); - if a != b { - #[cfg(feature = "variable_stroke_debug")] - if let Some(debug_edges) = self.debug_edges.as_mut() { - debug_edges.push(VariableStrokeDebugEdge { - a: self.adapter.int_to_float(&a), - b: self.adapter.int_to_float(&b), - kind, - path_index: self.debug_path_index, - order: debug_edges.len(), - }); - } - self.segments.push(Segment::subject(a, b)); - } - } -} - -#[cfg(test)] -mod tests { - use super::{ArcSweep, Cap, SegmentBuilder, SubSegment, VariableStrokeBuilder}; - #[cfg(feature = "variable_stroke_debug")] - use crate::mesh::float::variable_stroke::VariableStrokeDebugEdgeKind; - use crate::mesh::float::variable_stroke::offset::VariableStrokeOffset; - use crate::mesh::float::variable_stroke::section::Section; - use crate::mesh::float::variable_stroke::style::{StrokeVertex, VariableStrokeStyle}; - use crate::segm::boolean::ShapeCountBoolean; - use crate::segm::segment::Segment; - use alloc::vec; - use alloc::vec::Vec; - use i_float::adapter::FloatPointAdapter; - use i_float::float::rect::FloatRect; - - fn adapter() -> FloatPointAdapter<[f64; 2], i32> { - FloatPointAdapter::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 1.0) - } - - #[test] - fn variable_width_strokes_skip_coincident_integer_endpoints() { - for width in [0.0, 0.1, 1.0, 4.0] { - for end in [[10.0, 10.0], [0.0, 0.0], [20.0, 0.1]] { - let path = [ - StrokeVertex::new([0.0, 0.0], width), - StrokeVertex::new([0.01, 0.01], width), - StrokeVertex::new([10.0, 0.0], width * 2.0), - StrokeVertex::new(end, width), - ]; - let builder = VariableStrokeBuilder::new(VariableStrokeStyle::new().round_angle(0.01)); - let mut segments = Vec::>::new(); - builder.build(&path, &adapter(), &mut segments); - assert!( - segments.iter().all(|s| s.x_segment.a < s.x_segment.b), - "width={width}, end={end:?}" - ); - if width == 4.0 { - assert!(!segments.is_empty()); - } - } - } - } - - #[test] - fn empty_path_does_not_create_subsegments_or_edges() { - let path: [StrokeVertex<[f64; 2]>; 0] = []; - let adapter = adapter(); - let builder = VariableStrokeBuilder::new(VariableStrokeStyle::new()); - let mut segments = Vec::>::new(); - - assert!(VariableStrokeBuilder::::find_subsegments(&path, &adapter).is_empty()); - builder.build(&path, &adapter, &mut segments); - - assert!(segments.is_empty()); - } - - #[test] - fn single_round_vertex_builds_a_circle() { - let path = [StrokeVertex::new([0.0, 0.0], 4.0)]; - let adapter = adapter(); - let builder = VariableStrokeBuilder::new(VariableStrokeStyle::new()); - let mut segments = Vec::>::new(); - - builder.build(&path, &adapter, &mut segments); - - assert!(!segments.is_empty()); - } - - #[test] - fn covered_break_uses_butt_on_smaller_side() { - let path = [ - StrokeVertex::new([-20.0, 0.0], 4.0), - StrokeVertex::new([0.0, 0.0], 4.0), - StrokeVertex::new([2.0, 0.0], 20.0), - StrokeVertex::new([22.0, 0.0], 20.0), - ]; - let subsegments = VariableStrokeBuilder::::find_subsegments(&path, &adapter()); - assert_eq!(subsegments.len(), 2); - assert_eq!(subsegments[0].start, 0); - assert_eq!(subsegments[0].end, 1); - assert_eq!(subsegments[0].end_cap, Cap::Butt); - assert_eq!(subsegments[1].start, 2); - assert_eq!(subsegments[1].end, 3); - assert_eq!(subsegments[1].start_cap, Cap::Round); - } - - #[test] - fn reverse_covered_break_uses_butt_on_smaller_side() { - let path = [ - StrokeVertex::new([-20.0, 0.0], 20.0), - StrokeVertex::new([0.0, 0.0], 20.0), - StrokeVertex::new([2.0, 0.0], 4.0), - StrokeVertex::new([22.0, 0.0], 4.0), - ]; - let subsegments = VariableStrokeBuilder::::find_subsegments(&path, &adapter()); - assert_eq!(subsegments.len(), 2); - assert_eq!(subsegments[0].start, 0); - assert_eq!(subsegments[0].end, 1); - assert_eq!(subsegments[0].end_cap, Cap::Round); - assert_eq!(subsegments[1].start, 2); - assert_eq!(subsegments[1].end, 3); - assert_eq!(subsegments[1].start_cap, Cap::Butt); - } - - #[test] - fn near_covered_sections_stay_in_one_subsegment() { - let path = [ - StrokeVertex::new([0.0, 0.0], 6.0), - StrokeVertex::new([7.57, 3.86], 18.0), - StrokeVertex::new([19.2, 7.12], 42.0), - ]; - let precise_adapter: FloatPointAdapter<[f64; 2], i32> = - FloatPointAdapter::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 100.0); - let subsegments = VariableStrokeBuilder::::find_subsegments(&path, &precise_adapter); - assert_eq!(subsegments.len(), 1); - assert_eq!(subsegments[0].start, 0); - assert_eq!(subsegments[0].end, 2); - assert_eq!(subsegments[0].start_cap, Cap::Round); - assert_eq!(subsegments[0].end_cap, Cap::Round); - } - - #[test] - fn trapezoid_cover_requires_a_larger_source_circle() { - let equal_a = StrokeVertex::new([0.0, 0.0], 20.0); - let equal_b = StrokeVertex::new([100.0, 0.0], 20.0); - let c = StrokeVertex::new([50.0, 0.0], 20.0); - let larger_a = StrokeVertex::new([0.0, 0.0], 40.0); - let larger_b = StrokeVertex::new([100.0, 0.0], 40.0); - - assert!(!VariableStrokeBuilder::::circle_is_covered_by_section( - &equal_a, - &equal_b, - &c, - &adapter(), - )); - assert!(VariableStrokeBuilder::::circle_is_covered_by_section( - &larger_a, - &larger_b, - &c, - &adapter(), - )); - } - - #[test] - fn zero_length_butt_subsegment_is_not_drawn() { - let path = [ - StrokeVertex::new([-2.0, 0.0], 20.0), - StrokeVertex::new([0.0, 0.0], 2.0), - StrokeVertex::new([2.0, 0.0], 20.0), - ]; - let adapter = adapter(); - let subsegments = VariableStrokeBuilder::::find_subsegments(&path, &adapter); - - assert_eq!(subsegments.len(), 3); - assert_eq!( - subsegments[1], - SubSegment { - start: 1, - end: 1, - start_cap: Cap::Butt, - end_cap: Cap::Butt, - } - ); - - let builder = VariableStrokeBuilder::new(VariableStrokeStyle::new()); - let mut segments = Vec::>::new(); - let mut output = SegmentBuilder { - adapter: &adapter, - segments: &mut segments, - #[cfg(feature = "variable_stroke_debug")] - debug_edges: None, - #[cfg(feature = "variable_stroke_debug")] - debug_path_index: 0, - }; - builder.add_subsegment(&subsegments[1], &path, &mut output); - - assert!(segments.is_empty()); - } - - #[test] - fn join_keeps_all_tangent_contacts() { - let path = [ - StrokeVertex::new([-20.0, 0.0], 8.0), - StrokeVertex::new([0.0, 0.0], 20.0), - StrokeVertex::new([15.0, 18.0], 12.0), - ]; - let adapter: FloatPointAdapter<[f64; 2], i32> = - FloatPointAdapter::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 1_000.0); - let previous = Section::try_new(&path[0], &path[1], &adapter).unwrap(); - let next = Section::try_new(&path[1], &path[2], &adapter).unwrap(); - let contacts = [previous.b_left, previous.b_right, next.a_left, next.a_right]; - let mut segments = Vec::>::new(); - let mut output = SegmentBuilder { - adapter: &adapter, - segments: &mut segments, - #[cfg(feature = "variable_stroke_debug")] - debug_edges: None, - #[cfg(feature = "variable_stroke_debug")] - debug_path_index: 0, - }; - - output.add_join(&previous, &next, core::f64::consts::FRAC_PI_4); - - for contact in contacts { - let point = adapter.float_to_int(&contact); - assert!( - segments - .iter() - .any(|segment| segment.x_segment.a == point || segment.x_segment.b == point), - "missing tangent contact {point:?}" - ); - } - } - - fn join_arc_count(path: [StrokeVertex<[f64; 2]>; 3]) -> usize { - let adapter: FloatPointAdapter<[f64; 2], i32> = - FloatPointAdapter::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 1_000.0); - let previous = Section::try_new(&path[0], &path[1], &adapter).unwrap(); - let next = Section::try_new(&path[1], &path[2], &adapter).unwrap(); - let mut segments = Vec::>::new(); - let mut output = SegmentBuilder { - adapter: &adapter, - segments: &mut segments, - #[cfg(feature = "variable_stroke_debug")] - debug_edges: None, - #[cfg(feature = "variable_stroke_debug")] - debug_path_index: 0, - }; - - output.add_join(&previous, &next, core::f64::consts::FRAC_PI_4) - } - - #[test] - fn width_peak_builds_two_join_arcs() { - let path = [ - StrokeVertex::new([-10.0, 0.0], 4.0), - StrokeVertex::new([0.0, 0.0], 10.0), - StrokeVertex::new([10.0, 0.0], 4.0), - ]; - - assert_eq!(join_arc_count(path), 2); - } - - #[test] - fn ordinary_turn_builds_one_join_arc() { - let path = [ - StrokeVertex::new([-10.0, 0.0], 4.0), - StrokeVertex::new([0.0, 0.0], 4.0), - StrokeVertex::new([0.0, 10.0], 4.0), - ]; - - assert_eq!(join_arc_count(path), 1); - } - #[test] - fn coarse_arc_is_one_exact_contact_segment() { - let adapter: FloatPointAdapter<[f64; 2], i32> = - FloatPointAdapter::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 1_000.0); - let center = [0.0, 0.0]; - let from = [10.0, 0.0]; - let sweep = 0.1_f64; - let to = [10.0 * sweep.cos(), 10.0 * sweep.sin()]; - let mut segments = Vec::>::new(); - let mut output = SegmentBuilder { - adapter: &adapter, - segments: &mut segments, - #[cfg(feature = "variable_stroke_debug")] - debug_edges: None, - #[cfg(feature = "variable_stroke_debug")] - debug_path_index: 0, - }; - - assert!(output.add_arc_ccw( - ¢er, - &from, - &to, - core::f64::consts::FRAC_PI_4, - ArcSweep::Minor, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::JoinArc, - )); - assert_eq!(segments.len(), 1); - let edge = segments[0].x_segment; - let from = adapter.float_to_int(&from); - let to = adapter.float_to_int(&to); - assert!(edge.a == from || edge.b == from); - assert!(edge.a == to || edge.b == to); - } - - #[test] - fn coincident_contacts_keep_topological_major_arc() { - let adapter: FloatPointAdapter<[f64; 2], i32> = - FloatPointAdapter::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 1_000.0); - let center = [0.0, 0.0]; - let contact = [10.0, 0.0]; - let mut segments = Vec::>::new(); - let mut output = SegmentBuilder { - adapter: &adapter, - segments: &mut segments, - #[cfg(feature = "variable_stroke_debug")] - debug_edges: None, - #[cfg(feature = "variable_stroke_debug")] - debug_path_index: 0, - }; - - assert!(output.add_arc_ccw( - ¢er, - &contact, - &contact, - core::f64::consts::FRAC_PI_4, - ArcSweep::Major, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::JoinArc, - )); - assert!(segments.len() >= 3); - } - - #[test] - fn coarse_missed_arc_0() { - let paths = vec![vec![ - StrokeVertex::new([0.0_f32, 0.0_f32], 8.0_f32), - StrokeVertex::new([60.0_f32, 0.0_f32], 20.0_f32), - StrokeVertex::new([5.0_f32, 8.0_f32], 10.0_f32), - ]]; - let style = VariableStrokeStyle::new().round_angle(0.17999999_f32); - let result = paths.variable_stroke(style); - - assert!(!result.is_empty()); - } - - #[test] - fn coarse_missed_arc_1() { - let paths = vec![vec![ - StrokeVertex::new([0.0_f32, 0.0_f32], 8.0_f32), - StrokeVertex::new([60.0_f32, 0.0_f32], 20.0_f32), - StrokeVertex::new([60.0_f32, -60.0_f32], 10.0_f32), - ]]; - let style = VariableStrokeStyle::new().round_angle(0.17999999_f32); - let result = paths.variable_stroke(style); - - assert!(!result.is_empty()); - } - - #[test] - fn missed_arc_1() { - let paths = vec![vec![ - StrokeVertex::new([-86.0_f32, 2.0_f32], 10.0_f32), - StrokeVertex::new([100.0_f32, 0.0_f32], 100.0_f32), - StrokeVertex::new([99.0_f32, -45.0_f32], 10.0_f32), - ]]; - let precise_adapter: FloatPointAdapter<[f32; 2], i32> = - FloatPointAdapter::with_scale(FloatRect::new(-200.0, 200.0, -200.0, 200.0), 1_000.0); - let previous = Section::try_new(&paths[0][0], &paths[0][1], &precise_adapter).unwrap(); - let next = Section::try_new(&paths[0][1], &paths[0][2], &precise_adapter).unwrap(); - let mut segments = Vec::>::new(); - let mut output = SegmentBuilder { - adapter: &precise_adapter, - segments: &mut segments, - #[cfg(feature = "variable_stroke_debug")] - debug_edges: None, - #[cfg(feature = "variable_stroke_debug")] - debug_path_index: 0, - }; - - assert_eq!( - output.add_join(&previous, &next, 0.17999999_f32), - 2, - "the wide reversal exposes both join arcs" - ); - - let style = VariableStrokeStyle::new().round_angle(0.17999999_f32); - let result = paths.variable_stroke(style); - - assert!(!result.is_empty()); - } - - #[test] - fn missed_arc_2() { - // Dynamic Width repro: test=11 width_scale=2.2 - let paths = vec![vec![ - StrokeVertex::new([0.0_f32, 0.0_f32], 22.0_f32), - StrokeVertex::new([100.0_f32, 0.0_f32], 220.0_f32), - StrokeVertex::new([100.0_f32, -100.0_f32], 22.0_f32), - ]]; - let precise_adapter: FloatPointAdapter<[f32; 2], i32> = - FloatPointAdapter::with_scale(FloatRect::new(-250.0, 250.0, -250.0, 250.0), 1_000.0); - let first = Section::try_new(&paths[0][0], &paths[0][1], &precise_adapter).unwrap(); - let second = Section::try_new(&paths[0][1], &paths[0][2], &precise_adapter).unwrap(); - let mut join_segments = Vec::>::new(); - let mut output = SegmentBuilder { - adapter: &precise_adapter, - segments: &mut join_segments, - #[cfg(feature = "variable_stroke_debug")] - debug_edges: None, - #[cfg(feature = "variable_stroke_debug")] - debug_path_index: 0, - }; - - assert_eq!( - output.arc_sweep_ccw(&first.b, &second.a_left, &first.b_left, ArcSweep::Minor,), - ArcSweep::Major, - "the left CCW join crosses the major radial sector" - ); - assert_eq!(output.add_join(&first, &second, 0.21_f32), 2); - assert!(join_segments.len() > 20, "the major join arc was not built"); - - let style = VariableStrokeStyle::new().round_angle(0.21_f32); - let result = paths.variable_stroke(style); - - assert!(!result.is_empty()); - } - - #[test] - fn moderate_width_peak_builds_one_arc() { - // Dynamic Width repro: test=11 width_scale=0.88 - let paths = vec![vec![ - StrokeVertex::new([0.0_f32, 0.0_f32], 8.8_f32), - StrokeVertex::new([100.0_f32, 0.0_f32], 88.0_f32), - StrokeVertex::new([100.0_f32, -100.0_f32], 8.8_f32), - ]]; - let precise_adapter: FloatPointAdapter<[f32; 2], i32> = - FloatPointAdapter::with_scale(FloatRect::new(-150.0, 150.0, -150.0, 150.0), 1_000.0); - let first = Section::try_new(&paths[0][0], &paths[0][1], &precise_adapter).unwrap(); - let second = Section::try_new(&paths[0][1], &paths[0][2], &precise_adapter).unwrap(); - let mut join_segments = Vec::>::new(); - let mut output = SegmentBuilder { - adapter: &precise_adapter, - segments: &mut join_segments, - #[cfg(feature = "variable_stroke_debug")] - debug_edges: None, - #[cfg(feature = "variable_stroke_debug")] - debug_path_index: 0, - }; - - assert_eq!(output.add_join(&first, &second, 0.615_f32), 1); - - let style = VariableStrokeStyle::new().round_angle(0.615_f32); - let result = paths.variable_stroke(style); - - assert!(!result.is_empty()); - } - - #[test] - fn middle_left_reversal_closes_both_sections() { - let paths = vec![vec![ - StrokeVertex::new([-86.0_f32, 2.0_f32], 21.800001_f32), - StrokeVertex::new([100.0_f32, 0.0_f32], 218.0_f32), - StrokeVertex::new([-20.699999_f32, -16.029999_f32], 21.800001_f32), - ]]; - let precise_adapter: FloatPointAdapter<[f32; 2], i32> = - FloatPointAdapter::with_scale(FloatRect::new(-250.0, 250.0, -250.0, 250.0), 1_000.0); - let subsegments = VariableStrokeBuilder::::find_subsegments(&paths[0], &precise_adapter); - - assert_eq!(subsegments.len(), 2); - assert_eq!(subsegments[0].start, 0); - assert_eq!(subsegments[0].end, 1); - assert_eq!(subsegments[0].end_cap, Cap::Round); - assert_eq!(subsegments[1].start, 1); - assert_eq!(subsegments[1].end, 2); - assert_eq!(subsegments[1].start_cap, Cap::Butt); - assert_eq!(subsegments[1].end_cap, Cap::Butt); - - let result = paths.variable_stroke(VariableStrokeStyle::new().round_angle(0.75_f32)); - let has_tooth = result.iter().flatten().flatten().any(|point| { - let dx = point[0] - 100.0; - let dy = point[1]; - point[0] > 20.0 && point[1] < -70.0 && dx * dx + dy * dy < 108.5 * 108.5 - }); - - assert_eq!(result.len(), 1); - assert!(!has_tooth); - } - - #[test] - fn middle_right_reversal_closes_both_sections() { - let paths = vec![vec![ - StrokeVertex::new([-86.0_f32, -2.0_f32], 21.800001_f32), - StrokeVertex::new([100.0_f32, 0.0_f32], 218.0_f32), - StrokeVertex::new([-20.699999_f32, 16.029999_f32], 21.800001_f32), - ]]; - let precise_adapter: FloatPointAdapter<[f32; 2], i32> = - FloatPointAdapter::with_scale(FloatRect::new(-250.0, 250.0, -250.0, 250.0), 1_000.0); - let subsegments = VariableStrokeBuilder::::find_subsegments(&paths[0], &precise_adapter); - - assert_eq!(subsegments.len(), 2); - assert_eq!(subsegments[0].end_cap, Cap::Round); - assert_eq!(subsegments[1].start_cap, Cap::Butt); - assert_eq!(subsegments[1].end_cap, Cap::Butt); - - let result = paths.variable_stroke(VariableStrokeStyle::new().round_angle(0.75_f32)); - let has_tooth = result.iter().flatten().flatten().any(|point| { - let dx = point[0] - 100.0; - let dy = point[1]; - point[0] > 20.0 && point[1] > 70.0 && dx * dx + dy * dy < 108.5 * 108.5 - }); - - assert_eq!(result.len(), 1); - assert!(!has_tooth); - } -} diff --git a/iOverlay/src/mesh/float/variable_stroke/debug.rs b/iOverlay/src/mesh/float/variable_stroke/debug.rs index 205f46bd..11dadad0 100644 --- a/iOverlay/src/mesh/float/variable_stroke/debug.rs +++ b/iOverlay/src/mesh/float/variable_stroke/debug.rs @@ -1,21 +1,6 @@ use i_float::float::compatible::FloatPointCompatible; -/// The variable-stroke construction operation that emitted a raw pre-overlay edge. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum VariableStrokeDebugEdgeKind { - /// One of the two tangent boundaries of a drawable centerline section. - SectionBoundary, - /// One chord of a round join. - JoinArc, - /// A straight edge closing the gap between adjacent sections. - JoinClosure, - /// One chord of a round end cap. - CapArc, - /// A butt edge closing an end cap. - CapClosure, - /// One chord of a circle emitted for an isolated drawable vertex. - CircleArc, -} +pub use crate::mesh::int::variable_stroke::debug::VariableStrokeDebugEdgeKind; /// One directed edge submitted by `SegmentBuilder` before overlay processing. #[derive(Debug, Clone, Copy)] diff --git a/iOverlay/src/mesh/float/variable_stroke/mod.rs b/iOverlay/src/mesh/float/variable_stroke/mod.rs index 64ff3ae1..023140f6 100644 --- a/iOverlay/src/mesh/float/variable_stroke/mod.rs +++ b/iOverlay/src/mesh/float/variable_stroke/mod.rs @@ -1,9 +1,7 @@ -mod builder; #[cfg(feature = "variable_stroke_debug")] mod debug; pub mod offset; mod resource; -mod section; mod style; #[cfg(feature = "variable_stroke_debug")] diff --git a/iOverlay/src/mesh/float/variable_stroke/offset.rs b/iOverlay/src/mesh/float/variable_stroke/offset.rs index 4b389b46..62510e7b 100644 --- a/iOverlay/src/mesh/float/variable_stroke/offset.rs +++ b/iOverlay/src/mesh/float/variable_stroke/offset.rs @@ -1,18 +1,17 @@ -use crate::core::fill_rule::FillRule; use crate::core::integer::OverlayInt; -use crate::core::overlay::Overlay; -use crate::core::overlay_rule::OverlayRule; use crate::float::overlay::OverlayOptions; use crate::float::scale::FixedScaleOverlayError; -use crate::mesh::float::variable_stroke::builder::VariableStrokeBuilder; use crate::mesh::float::variable_stroke::resource::VariableStrokeSource; use crate::mesh::float::variable_stroke::style::VariableStrokeStyle; +use crate::mesh::int::variable_stroke::offset::IntVariableStrokeOffset; +use crate::mesh::int::variable_stroke::{IntStrokeVertex, IntVariableStrokeStyle}; use alloc::vec; use alloc::vec::Vec; use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; use i_float::float::number::FloatNumber; use i_float::float::rect::FloatRect; +use i_float::int::angle::Angle; use i_float::int::number::int::IntNumber; use i_float::int::number::uint::UIntNumber; use i_float::int::number::wide_int::WideIntNumber; @@ -239,10 +238,8 @@ where struct VariableStrokeSolver { max_radius: P::Scalar, - builder: VariableStrokeBuilder, + style: VariableStrokeStyle, adapter: FloatPointAdapter, - paths_count: usize, - points_count: usize, } impl VariableStrokeSolver @@ -279,17 +276,15 @@ where return None; } - let builder = VariableStrokeBuilder::new(style); + let style = style.normalized(); let mut rect = rect?; - rect.add_offset(builder.additional_offset(max_radius)); + rect.add_offset(P::Scalar::from_float(1.1) * max_radius); let adapter = FloatPointAdapter::::new(rect); Some(Self { max_radius, - builder, + style, adapter, - paths_count, - points_count, }) } @@ -298,6 +293,31 @@ where Ok(()) } + fn int_style(&self) -> IntVariableStrokeStyle { + IntVariableStrokeStyle { + arc: crate::mesh::int::arc::ArcOptions { + max_step: Angle::from_radians(self.style.round_angle), + ..Default::default() + }, + } + } + fn int_paths + ?Sized>(&self, source: &S) -> Vec>> { + source + .iter_variable_paths() + .map(|path| { + path.iter() + .map(|v| { + let radius = self.adapter.round_len_to_int(v.radius()).to_wide(); + IntStrokeVertex::new( + self.adapter.float_to_int(&v.point), + I::from_wide(radius + radius), + ) + }) + .collect() + }) + .collect() + } + fn build + ?Sized>( self, source: &S, @@ -307,14 +327,10 @@ where return vec![]; } - let mut segments = Vec::with_capacity(self.builder.capacity(self.paths_count, self.points_count)); - for path in source.iter_variable_paths() { - self.builder.build(path, &self.adapter, &mut segments); - } - - let mut overlay = Overlay::with_segments(segments); - overlay.options = options.int_with_adapter(&self.adapter); - let shapes = overlay.overlay(OverlayRule::Subject, FillRule::Positive); + let paths = self.int_paths(source); + let shapes = paths + .variable_stroke_custom(self.int_style(), options.int_with_adapter(&self.adapter)) + .expect("valid integer variable stroke"); let mut float = shapes.to_float(&self.adapter); if options.clean_result { @@ -338,15 +354,15 @@ where return; } - let mut segments = Vec::with_capacity(self.builder.capacity(self.paths_count, self.points_count)); - for path in source.iter_variable_paths() { - self.builder.build(path, &self.adapter, &mut segments); - } - - let mut overlay = Overlay::with_segments(segments); - overlay.options = options.int_with_adapter(&self.adapter); + let paths = self.int_paths(source); let mut int_output = FlatContoursBuffer::::with_capacity(0); - overlay.overlay_into(OverlayRule::Subject, FillRule::Positive, &mut int_output); + paths + .variable_stroke_custom_into( + self.int_style(), + options.int_with_adapter(&self.adapter), + &mut int_output, + ) + .expect("valid integer variable stroke"); let iter = int_output .points @@ -375,16 +391,24 @@ where }; } - let mut segments = Vec::with_capacity(self.builder.capacity(self.paths_count, self.points_count)); - let mut edges = Vec::with_capacity(segments.capacity()); - for (path_index, path) in source.iter_variable_paths().enumerate() { - self.builder - .build_debug(path, path_index, &self.adapter, &mut segments, &mut edges); - } - - let mut overlay = Overlay::with_segments(segments); - overlay.options = options.int_with_adapter(&self.adapter); - let shapes = overlay.overlay(OverlayRule::Subject, FillRule::Positive); + let paths = self.int_paths(source); + let result = paths + .variable_stroke_debug(self.int_style(), options.int_with_adapter(&self.adapter)) + .expect("valid integer variable stroke"); + let edges = result + .edges + .into_iter() + .map( + |edge| crate::mesh::float::variable_stroke::VariableStrokeDebugEdge { + a: self.adapter.int_to_float(&edge.a), + b: self.adapter.int_to_float(&edge.b), + kind: edge.kind, + path_index: edge.path_index, + order: edge.order, + }, + ) + .collect(); + let shapes = result.shapes; let mut shapes = shapes.to_float(&self.adapter); if options.clean_result { diff --git a/iOverlay/src/mesh/float/variable_stroke/section.rs b/iOverlay/src/mesh/float/variable_stroke/section.rs deleted file mode 100644 index 2f003142..00000000 --- a/iOverlay/src/mesh/float/variable_stroke/section.rs +++ /dev/null @@ -1,165 +0,0 @@ -use crate::mesh::float::math::Math; -use crate::mesh::float::variable_stroke::style::StrokeVertex; -use i_float::adapter::FloatPointAdapter; -use i_float::float::compatible::FloatPointCompatible; -use i_float::float::number::FloatNumber; -use i_float::float::vector::FloatPointMath; -use i_float::int::number::int::IntNumber; -use i_float::int::number::wide_int::WideIntNumber; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum RadiusTrend { - Decreasing, - Constant, - Increasing, -} - -#[derive(Clone, Copy)] -pub(super) struct Section { - pub(super) a: P, - pub(super) b: P, - pub(super) a_left: P, - pub(super) b_left: P, - pub(super) a_right: P, - pub(super) b_right: P, - pub(super) radius_trend: RadiusTrend, -} - -impl Section

{ - pub(super) fn try_new( - a: &StrokeVertex

, - b: &StrokeVertex

, - adapter: &FloatPointAdapter, - ) -> Option { - let int_a = adapter.float_to_int(&a.point); - let int_b = adapter.float_to_int(&b.point); - if int_a == int_b { - return None; - } - - let int_a_radius = adapter.round_len_to_int(a.radius()); - let int_b_radius = adapter.round_len_to_int(b.radius()); - if int_a_radius.max(int_b_radius) <= I::ONE { - return None; - } - let radius_trend = if int_a_radius < int_b_radius { - RadiusTrend::Increasing - } else if int_a_radius > int_b_radius { - RadiusTrend::Decreasing - } else { - RadiusTrend::Constant - }; - - let int_radius_delta = int_a_radius.to_wide() - int_b_radius.to_wide(); - let vector = int_b - int_a; - let int_distance_sqr = vector.sqr_length(); - - if (int_radius_delta * int_radius_delta).to_uint() >= int_distance_sqr { - return None; - } - - let a = adapter.int_to_float(&int_a); - let b = adapter.int_to_float(&int_b); - let a_radius = adapter.len_to_float(int_a_radius); - let b_radius = adapter.len_to_float(int_b_radius); - - Some(Self::new(a_radius, b_radius, &a, &b, radius_trend)) - } - - fn new(a_radius: P::Scalar, b_radius: P::Scalar, a: &P, b: &P, radius_trend: RadiusTrend) -> Self { - let direction = Math::normal(b, a); - let center_vector = FloatPointMath::sub(b, a); - let distance_sqr = FloatPointMath::sqr_length(¢er_vector); - let distance = distance_sqr.sqrt(); - let radius_delta = a_radius - b_radius; - let k = radius_delta / distance; - let h = (P::Scalar::ONE - k * k).max(P::Scalar::ZERO).sqrt(); - - let normal = P::from_xy(-direction.y(), direction.x()); - let left_normal = P::from_xy( - k * direction.x() + h * normal.x(), - k * direction.y() + h * normal.y(), - ); - let right_normal = P::from_xy( - k * direction.x() - h * normal.x(), - k * direction.y() - h * normal.y(), - ); - - let a_left = FloatPointMath::add(a, &FloatPointMath::scale(&left_normal, a_radius)); - let b_left = FloatPointMath::add(b, &FloatPointMath::scale(&left_normal, b_radius)); - let a_right = FloatPointMath::add(a, &FloatPointMath::scale(&right_normal, a_radius)); - let b_right = FloatPointMath::add(b, &FloatPointMath::scale(&right_normal, b_radius)); - - Self { - a: *a, - b: *b, - a_left, - b_left, - a_right, - b_right, - radius_trend, - } - } -} - -#[cfg(test)] -mod tests { - use super::{RadiusTrend, Section}; - use crate::mesh::float::variable_stroke::StrokeVertex; - use i_float::adapter::FloatPointAdapter; - use i_float::float::rect::FloatRect; - - fn adapter() -> FloatPointAdapter<[f64; 2], i32> { - FloatPointAdapter::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 1.0) - } - - #[test] - fn equal_width_has_parallel_tangents() { - let a = StrokeVertex::new([0.0, 0.0], 4.0); - let b = StrokeVertex::new([10.0, 0.0], 4.0); - let section = Section::try_new(&a, &b, &adapter()).unwrap(); - - assert_eq!(section.a_left, [0.0, 2.0]); - assert_eq!(section.b_left, [10.0, 2.0]); - assert_eq!(section.a_right, [0.0, -2.0]); - assert_eq!(section.b_right, [10.0, -2.0]); - assert_eq!(section.radius_trend, RadiusTrend::Constant); - } - - #[test] - fn radius_trend_uses_adapter_radii() { - let increasing = Section::try_new( - &StrokeVertex::new([0.0, 0.0], 4.0), - &StrokeVertex::new([10.0, 0.0], 6.0), - &adapter(), - ) - .unwrap(); - let decreasing = Section::try_new( - &StrokeVertex::new([0.0, 0.0], 6.0), - &StrokeVertex::new([10.0, 0.0], 4.0), - &adapter(), - ) - .unwrap(); - - assert_eq!(increasing.radius_trend, RadiusTrend::Increasing); - assert_eq!(decreasing.radius_trend, RadiusTrend::Decreasing); - } - - #[test] - fn points_equal_in_int_space_are_zero() { - let adapter: FloatPointAdapter<[f64; 2], i32> = - FloatPointAdapter::with_scale(FloatRect::new(-100.0, 100.0, -100.0, 100.0), 10.0); - let a = StrokeVertex::new([0.01, 0.01], 4.0); - let b = StrokeVertex::new([0.04, 0.04], 4.0); - - assert!(Section::try_new(&a, &b, &adapter).is_none()); - } - - #[test] - fn radius_at_most_one_in_int_space_is_zero() { - let a = StrokeVertex::new([0.0, 0.0], 2.0); - let b = StrokeVertex::new([10.0, 0.0], 2.0); - - assert!(Section::try_new(&a, &b, &adapter()).is_none()); - } -} diff --git a/iOverlay/src/mesh/int/bounds.rs b/iOverlay/src/mesh/int/bounds.rs new file mode 100644 index 00000000..6e9d388f --- /dev/null +++ b/iOverlay/src/mesh/int/bounds.rs @@ -0,0 +1,12 @@ +use i_float::int::{number::int::IntNumber, rect::IntRect}; +pub(super) fn expanded_is_safe(rect: IntRect, padding: I::Wide) -> bool { + let min = I::MIN.to_wide(); + let max = I::MAX.to_wide(); + IntRect::new( + I::from_wide((rect.min_x.to_wide() - padding).max(min)), + I::from_wide((rect.max_x.to_wide() + padding).min(max)), + I::from_wide((rect.min_y.to_wide() - padding).max(min)), + I::from_wide((rect.max_y.to_wide() + padding).min(max)), + ) + .is_in_safe_range() +} diff --git a/iOverlay/src/mesh/int/join.rs b/iOverlay/src/mesh/int/join.rs new file mode 100644 index 00000000..f638a645 --- /dev/null +++ b/iOverlay/src/mesh/int/join.rs @@ -0,0 +1,125 @@ +use super::arc::{ArcBuilder, ArcDirection}; +use super::math::{abs, mul_div, point, scaled_point, vector}; +use super::style::IntLineJoin; +use crate::mesh::subject::SubjectSegments; +use crate::segm::{boolean::ShapeCountBoolean, segment::Segment}; +use alloc::vec::Vec; +use i_float::int::angle::Angle; +use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; +use i_float::int::{point::IntPoint, unit_vector::UnitIntVector}; + +pub(super) enum Join { + Bevel, + Miter { minimum: u32, sin: i32, cos: i32 }, + Round(ArcBuilder), +} + +impl Join { + pub(super) fn new(style: IntLineJoin) -> Self { + match style { + IntLineJoin::Bevel => Self::Bevel, + IntLineJoin::Round(options) => Self::Round(ArcBuilder::new(options)), + IntLineJoin::Miter(angle) => { + let minimum = angle + .bits() + .clamp((1u32 << 31) / 100, ((1u64 << 31) * 99 / 100) as u32); + let (sin, cos) = Angle::from_bits(minimum / 2).sin_cos(); + Self::Miter { minimum, sin, cos } + } + } + } + + pub(super) fn padding(style: IntLineJoin, radius: I) -> I::Wide { + let radius = abs(radius); + if radius == I::Wide::ZERO { + return radius; + } + match style { + IntLineJoin::Miter(_) => { + let Self::Miter { sin, .. } = Self::new(style) else { + unreachable!() + }; + mul_div::(radius, I::Wide::from_u32(1 << 30), I::Wide::from_u32(sin as u32)) + I::Wide::TWO + } + _ => radius, + } + } + + /// Joins the two outward offset rays, in boundary traversal order. + pub(super) fn add( + &mut self, + center: IntPoint, + a: IntPoint, + b: IntPoint, + incoming: UnitIntVector, + outgoing: UnitIntVector, + radius: I, + direction: ArcDirection, + segments: &mut Vec>, + ) { + if a == b { + return; + } + match self { + Self::Bevel => segments.push_non_degenerate(a, b), + Self::Round(arc) => { + let Some(from) = crate::mesh::int::math::direction(a - center) else { + segments.push_non_degenerate(a, b); + return; + }; + let Some(to) = crate::mesh::int::math::direction(b - center) else { + segments.push_non_degenerate(a, b); + return; + }; + let mut previous = a; + for &direction in arc.build(from, to, direction) { + let next = scaled_point(center, direction, radius); + segments.push_non_degenerate(previous, next); + previous = next; + } + segments.push_non_degenerate(previous, b); + } + Self::Miter { minimum, sin, cos } => { + let va = vector(incoming); + let vb = vector(outgoing); + let cross = va.cross_product(vb); + if cross == I::Wide::ZERO { + segments.push_non_degenerate(a, b); + return; + } + let turn = Angle::between(incoming, outgoing).bits(); + let turn = turn.min(turn.wrapping_neg()); + if (1u32 << 31) - turn < *minimum { + let extension = mul_div::( + abs(radius), + I::Wide::from_u32(*cos as u32), + I::Wide::from_u32(*sin as u32), + ); + let scale = UnitIntVector::::DENOMINATOR; + let ac = point( + a, + mul_div::(va.x, extension, scale), + mul_div::(va.y, extension, scale), + ); + let bc = point( + b, + -mul_div::(vb.x, extension, scale), + -mul_div::(vb.y, extension, scale), + ); + segments.push_non_degenerate(a, ac); + segments.push_non_degenerate(ac, bc); + segments.push_non_degenerate(bc, b); + } else { + let numerator = (b - a).cross_product(vb); + let peak = point( + a, + mul_div::(va.x, numerator, cross), + mul_div::(va.y, numerator, cross), + ); + segments.push_non_degenerate(a, peak); + segments.push_non_degenerate(peak, b); + } + } + } + } +} diff --git a/iOverlay/src/mesh/int/math.rs b/iOverlay/src/mesh/int/math.rs new file mode 100644 index 00000000..a09103e2 --- /dev/null +++ b/iOverlay/src/mesh/int/math.rs @@ -0,0 +1,47 @@ +use i_float::int::number::{ + int::IntNumber, product_uint::UIntProduct, uint::UIntNumber, wide_int::WideIntNumber, +}; +use i_float::int::{point::IntPoint, unit_vector::UnitIntVector, vector::IntVector}; + +pub(super) fn mul_div(a: I::Wide, b: I::Wide, divisor: I::Wide) -> I::Wide { + let negative = (a < I::Wide::ZERO) ^ (b < I::Wide::ZERO) ^ (divisor < I::Wide::ZERO); + let product = ::Product::multiply(a.unsigned_abs(), b.unsigned_abs()); + let value = I::Wide::from_uint(product.divide_with_rounding(divisor.unsigned_abs())); + if negative { -value } else { value } +} + +pub(super) fn vector(direction: UnitIntVector) -> IntVector { + IntVector::new(direction.x().to_wide(), direction.y().to_wide()) +} + +pub(super) fn point(center: IntPoint, dx: I::Wide, dy: I::Wide) -> IntPoint { + let x = center.x.to_wide() + dx; + let y = center.y.to_wide() + dy; + debug_assert!(x > -UnitIntVector::::DENOMINATOR && x < UnitIntVector::::DENOMINATOR); + debug_assert!(y > -UnitIntVector::::DENOMINATOR && y < UnitIntVector::::DENOMINATOR); + IntPoint::new(I::from_wide(x), I::from_wide(y)) +} + +pub(super) fn scaled_point( + center: IntPoint, + direction: UnitIntVector, + radius: I, +) -> IntPoint { + let offset = direction.scale(radius); + point(center, offset.x, offset.y) +} + +pub(super) fn abs(value: I) -> I::Wide { + let wide = value.to_wide(); + if wide < I::Wide::ZERO { -wide } else { wide } +} + +pub(super) fn direction(v: IntVector) -> Option> { + if v.x == I::Wide::ZERO { + IntVector::::new(I::Wide::ZERO, v.y.signum()).fast_normalize() + } else if v.y == I::Wide::ZERO { + IntVector::::new(v.x.signum(), I::Wide::ZERO).fast_normalize() + } else { + v.fast_normalize() + } +} diff --git a/iOverlay/src/mesh/int/mod.rs b/iOverlay/src/mesh/int/mod.rs index 82489cfc..11250f28 100644 --- a/iOverlay/src/mesh/int/mod.rs +++ b/iOverlay/src/mesh/int/mod.rs @@ -2,3 +2,11 @@ pub mod arc; pub mod outline; pub mod style; + +mod join; +mod math; + +mod bounds; +pub mod stroke; + +pub mod variable_stroke; diff --git a/iOverlay/src/mesh/int/outline/bounds.rs b/iOverlay/src/mesh/int/outline/bounds.rs index 71b6eaff..56286d82 100644 --- a/iOverlay/src/mesh/int/outline/bounds.rs +++ b/iOverlay/src/mesh/int/outline/bounds.rs @@ -1,6 +1,6 @@ -use super::builder_join::{BevelJoinBuilder, JoinBuilder}; use super::offset::IntOutlineError; -use crate::mesh::int::style::{IntLineJoin, IntOutlineStyle}; +use crate::mesh::int::join::Join; +use crate::mesh::int::style::IntOutlineStyle; use i_float::int::number::int::IntNumber; use i_float::int::rect::IntRect; use i_shape::source::int::resource::IntShapeResource; @@ -9,12 +9,8 @@ pub(super) fn validate + ?Sized>( source: &S, style: &IntOutlineStyle, ) -> Result<(), IntOutlineError> { - let padding = match style.join { - IntLineJoin::Bevel => BevelJoinBuilder - .additional_offset(style.outer_offset) - .max(BevelJoinBuilder.additional_offset(style.inner_offset)), - join => return Err(IntOutlineError::UnsupportedJoin(join)), - }; + let padding = + Join::padding(style.join, style.outer_offset).max(Join::padding(style.join, style.inner_offset)); let Some(rect) = IntRect::with_iter(source.iter_paths().flatten()) else { return Ok(()); }; diff --git a/iOverlay/src/mesh/int/outline/build.rs b/iOverlay/src/mesh/int/outline/build.rs index 56c91f24..86f19b9f 100644 --- a/iOverlay/src/mesh/int/outline/build.rs +++ b/iOverlay/src/mesh/int/outline/build.rs @@ -1,13 +1,14 @@ use super::bounds; use super::builder::OutlineBuilder; -use super::builder_join::{BevelJoinBuilder, JoinBuilder}; +use super::builder_join::JoinBuilder; use super::offset::IntOutlineError; use crate::core::extract::BooleanExtractionBuffer; use crate::core::fill_rule::FillRule; use crate::core::integer::OverlayInt; use crate::core::overlay::{ContourDirection, IntOverlayOptions, Overlay, ShapeType}; use crate::core::overlay_rule::OverlayRule; -use crate::mesh::int::style::{IntLineJoin, IntOutlineStyle}; +use crate::mesh::int::join::Join; +use crate::mesh::int::style::IntOutlineStyle; use alloc::vec::Vec; use i_float::int::number::uint::UIntNumber; use i_float::int::number::wide_int::WideIntNumber; @@ -21,27 +22,22 @@ pub(super) trait BuildOutlineOverlay: IntShapeResource { style: &IntOutlineStyle, options: IntOverlayOptions, ) -> Result, IntOutlineError> { - match style.join { - IntLineJoin::Bevel => { - debug_assert!( - bounds::validate(self, style).is_ok(), - "outline bounds exceed the safe coordinate range" - ); - Ok(self.build_overlay_with_builders( - options, - OutlineBuilder::new(style.outer_offset, BevelJoinBuilder), - OutlineBuilder::new(style.inner_offset, BevelJoinBuilder), - )) - } - join => Err(IntOutlineError::UnsupportedJoin(join)), - } + debug_assert!( + bounds::validate(self, style).is_ok(), + "outline bounds exceed the safe coordinate range" + ); + Ok(self.build_overlay_with_builders( + options, + OutlineBuilder::new(style.outer_offset, Join::new(style.join)), + OutlineBuilder::new(style.inner_offset, Join::new(style.join)), + )) } fn build_overlay_with_builders>( &self, options: IntOverlayOptions, - outer_builder: OutlineBuilder, - inner_builder: OutlineBuilder, + mut outer_builder: OutlineBuilder, + mut inner_builder: OutlineBuilder, ) -> Overlay { let mut overlay = Overlay::new_custom(0, options, Default::default()); let mut contour_options = options; @@ -64,12 +60,16 @@ pub(super) trait BuildOutlineOverlay: IntShapeResource { } let (builder, direction, fill) = if area > I::Wide::ZERO { ( - &outer_builder, + &mut outer_builder, ContourDirection::CounterClockwise, FillRule::Positive, ) } else { - (&inner_builder, ContourDirection::Clockwise, FillRule::Negative) + ( + &mut inner_builder, + ContourDirection::Clockwise, + FillRule::Negative, + ) }; segments.clear(); builder.build(path, &mut segments); diff --git a/iOverlay/src/mesh/int/outline/builder.rs b/iOverlay/src/mesh/int/outline/builder.rs index 9e8d134f..0e41adba 100644 --- a/iOverlay/src/mesh/int/outline/builder.rs +++ b/iOverlay/src/mesh/int/outline/builder.rs @@ -19,7 +19,7 @@ impl> OutlineBuilder { Self { offset, join_builder } } - pub(super) fn build(&self, path: &[IntPoint], segments: &mut Vec>) { + pub(super) fn build(&mut self, path: &[IntPoint], segments: &mut Vec>) { let Some(mut iter) = UniqueSegmentsIter::new(path.iter().copied()) else { return; }; @@ -40,7 +40,7 @@ impl> OutlineBuilder { #[inline] fn feed_join( - &self, + &mut self, previous: &OffsetSection, next: &OffsetSection, segments: &mut Vec>, diff --git a/iOverlay/src/mesh/int/outline/builder_join.rs b/iOverlay/src/mesh/int/outline/builder_join.rs index 3da3a201..19af290b 100644 --- a/iOverlay/src/mesh/int/outline/builder_join.rs +++ b/iOverlay/src/mesh/int/outline/builder_join.rs @@ -1,40 +1,41 @@ use super::section::OffsetSection; -use crate::mesh::subject::SubjectSegments; use crate::segm::boolean::ShapeCountBoolean; use crate::segm::segment::Segment; use alloc::vec::Vec; use i_float::int::number::int::IntNumber; -use i_float::int::number::wide_int::WideIntNumber; pub(super) trait JoinBuilder { /// Connects the offset endpoints of an outer corner. The endpoints differ. fn add_join( - &self, + &mut self, previous: &OffsetSection, next: &OffsetSection, segments: &mut Vec>, ); - - /// Nonnegative conservative padding, including temporary join points. - fn additional_offset(&self, offset: I) -> I::Wide; } -pub(super) struct BevelJoinBuilder; - -impl JoinBuilder for BevelJoinBuilder { - #[inline] +impl JoinBuilder for crate::mesh::int::join::Join { fn add_join( - &self, + &mut self, previous: &OffsetSection, next: &OffsetSection, segments: &mut Vec>, ) { - segments.push_non_degenerate(previous.b_top, next.a_top); - } - - #[inline] - fn additional_offset(&self, offset: I) -> I::Wide { - let offset = offset.to_wide(); - if offset < I::Wide::ZERO { -offset } else { offset } + let direction = if previous.offset >= I::ZERO { + crate::mesh::int::arc::ArcDirection::Counterclockwise + } else { + crate::mesh::int::arc::ArcDirection::Clockwise + }; + let radius = I::from_wide(crate::mesh::int::math::abs(previous.offset)); + self.add( + previous.b, + previous.b_top, + next.a_top, + previous.direction, + next.direction, + radius, + direction, + segments, + ); } } diff --git a/iOverlay/src/mesh/int/outline/offset.rs b/iOverlay/src/mesh/int/outline/offset.rs index c2eaec2a..7659549d 100644 --- a/iOverlay/src/mesh/int/outline/offset.rs +++ b/iOverlay/src/mesh/int/outline/offset.rs @@ -1,30 +1,26 @@ -//! Integer outline construction. Bevel joins are implemented; angle-based joins -//! are explicit placeholders. Float outline remains a separate API for now. +//! Integer outline construction used directly and by the float adapter. use super::bounds; use super::build::BuildOutlineOverlay; use crate::core::fill_rule::FillRule; use crate::core::integer::OverlayInt; use crate::core::overlay::IntOverlayOptions; use crate::core::overlay_rule::OverlayRule; -use crate::mesh::int::style::{IntLineJoin, IntOutlineStyle}; +use crate::mesh::int::style::IntOutlineStyle; use i_shape::flat::buffer::FlatContoursBuffer; use i_shape::int::shape::IntShapes; use i_shape::source::int::resource::IntShapeResource; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IntOutlineError { - /// Miter and round joins await integer angle support. - UnsupportedJoin(IntLineJoin), /// The conservative expanded input bounds fail the coordinate-range check. - /// Returned by `validate_outline`, not by outline construction. + /// Returned by optional mesh validation, not by construction. CoordinateOutOfRange, } impl core::fmt::Display for IntOutlineError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { - Self::UnsupportedJoin(join) => write!(f, "integer {join:?} joins are not implemented"), - Self::CoordinateOutOfRange => f.write_str("outline coordinate exceeds the integer engine range"), + Self::CoordinateOutOfRange => f.write_str("mesh coordinate exceeds the integer engine range"), } } } @@ -63,13 +59,13 @@ impl core::error::Error for IntOutlineError {} /// # Ok::<(), i_overlay::mesh::int::outline::offset::IntOutlineError>(()) /// ``` pub trait IntOutlineOffset: IntShapeResource { - /// Checks supported joins and the coordinate range of the prospective operation. + /// Checks the coordinate range of the prospective operation. /// /// Computes input bounds, expands by the maximum outer/inner join padding, /// and checks `IntRect::is_in_safe_range`. Padding uses absolute offsets even /// for shrinking outlines, since temporary edges must also stay in range. /// The estimate is conservative and may reject geometry whose actual points - /// would fit. Empty input passes for supported joins. This does not validate + /// would fit. Empty input passes. This does not validate /// winding, topology, or the accumulated area of repeated winding. fn validate_outline(&self, style: &IntOutlineStyle) -> Result<(), IntOutlineError> { bounds::validate(self, style) diff --git a/iOverlay/src/mesh/int/outline/section.rs b/iOverlay/src/mesh/int/outline/section.rs index 223b292f..50dd2844 100644 --- a/iOverlay/src/mesh/int/outline/section.rs +++ b/iOverlay/src/mesh/int/outline/section.rs @@ -1,9 +1,12 @@ use crate::mesh::uniq_iter::UniqueSegment; use i_float::int::number::int::IntNumber; use i_float::int::point::IntPoint; +use i_float::int::unit_vector::UnitIntVector; #[derive(Clone, Copy)] pub(super) struct OffsetSection { + pub(super) offset: I, + pub(super) direction: UnitIntVector, pub(super) a: IntPoint, pub(super) b: IntPoint, pub(super) a_top: IntPoint, @@ -13,17 +16,17 @@ pub(super) struct OffsetSection { impl OffsetSection { pub(super) fn new(segment: UniqueSegment, offset: I) -> Self { let (a, b) = (segment.a, segment.b); + let direction = crate::mesh::int::math::direction(b - a).expect("unique segment"); if offset == I::ZERO { return Self { + offset, + direction, a, b, a_top: a, b_top: b, }; } - let direction = (b - a) - .fast_normalize() - .expect("unique segments must have nonzero length"); let scaled = direction.scale(offset); let dx = scaled.y; @@ -33,6 +36,13 @@ impl OffsetSection { debug_assert!(a_top.is_in_safe_range() && b_top.is_in_safe_range()); - Self { a, b, a_top, b_top } + Self { + offset, + direction, + a, + b, + a_top, + b_top, + } } } diff --git a/iOverlay/src/mesh/int/stroke/builder.rs b/iOverlay/src/mesh/int/stroke/builder.rs new file mode 100644 index 00000000..3ca828a2 --- /dev/null +++ b/iOverlay/src/mesh/int/stroke/builder.rs @@ -0,0 +1,290 @@ +use crate::mesh::int::{ + arc::{ArcBuilder, ArcDirection}, + join::Join, + math::{point, scaled_point, vector}, + style::{IntLineCap, IntStrokeStyle}, +}; +use crate::mesh::subject::SubjectSegments; +use crate::segm::{boolean::ShapeCountBoolean, segment::Segment}; +use alloc::vec::Vec; +use i_float::int::number::{int::IntNumber, uint::UIntNumber, wide_int::WideIntNumber}; +use i_float::int::{point::IntPoint, unit_vector::UnitIntVector}; + +#[derive(Clone, Copy)] +struct Section { + a: IntPoint, + b: IntPoint, + a_left: IntPoint, + a_right: IntPoint, + b_left: IntPoint, + b_right: IntPoint, + dir: UnitIntVector, +} + +impl Section { + fn new(a: IntPoint, b: IntPoint, radius: I) -> Self { + let dir = crate::mesh::int::math::direction(b - a).expect("unique section"); + let v = dir.scale(radius); + Self { + a, + b, + dir, + a_left: point(a, -v.y, v.x), + a_right: point(a, v.y, -v.x), + b_left: point(b, -v.y, v.x), + b_right: point(b, v.y, -v.x), + } + } + fn add(&self, segments: &mut Vec>) { + for (a, b) in [(self.a_right, self.b_right), (self.b_left, self.a_left)] { + segments.push_non_degenerate(a, b); + } + } +} + +enum Cap { + Butt, + Square, + Round(ArcBuilder), + Custom(alloc::rc::Rc<[IntPoint]>), +} +impl Cap { + fn new(cap: &IntLineCap) -> Self { + match cap { + IntLineCap::Butt => Self::Butt, + IntLineCap::Square => Self::Square, + IntLineCap::Round(options) => Self::Round(ArcBuilder::new(*options)), + IntLineCap::Custom(points) => Self::Custom(points.clone()), + } + } + fn add( + &mut self, + center: IntPoint, + from: IntPoint, + to: IntPoint, + outward: UnitIntVector, + radius: I, + segments: &mut Vec>, + ) { + if matches!(self, Self::Butt) { + segments.push_non_degenerate(from, to); + return; + } + let mut previous = from; + match self { + Self::Round(arc) => { + let a = crate::mesh::int::math::direction(from - center).expect("positive radius"); + let b = crate::mesh::int::math::direction(to - center).expect("positive radius"); + for &dir in arc.build(a, b, ArcDirection::Counterclockwise) { + let next = scaled_point(center, dir, radius); + segments.push_non_degenerate(previous, next); + previous = next; + } + } + Self::Square => { + let v = outward.scale(radius); + for next in [point(from, v.x, v.y), point(to, v.x, v.y)] { + segments.push_non_degenerate(previous, next); + previous = next; + } + } + Self::Custom(points) => { + let v = vector(outward); + let shift = UnitIntVector::::DENOMINATOR.ilog2(); + for p in points.iter() { + // Both products and their sum fit in Wide. Round once so + // the rotated cap stays within its conservative bounds. + let x = (v.x * p.x.to_wide() - v.y * p.y.to_wide()).shr_round(shift); + let y = (v.y * p.x.to_wide() + v.x * p.y.to_wide()).shr_round(shift); + let next = point(center, x, y); + segments.push_non_degenerate(previous, next); + previous = next; + } + } + Self::Butt => {} + } + segments.push_non_degenerate(previous, to); + } +} + +pub(super) struct StrokeBuilder { + radius: I, + join: Join, + start: Cap, + end: Cap, + sections: Vec>, +} +impl StrokeBuilder { + pub(super) fn radius(style: &IntStrokeStyle) -> I { + I::from_wide((style.width.max(I::ZERO).to_wide() + I::Wide::ONE) / I::Wide::TWO) + } + pub(super) fn padding(style: &IntStrokeStyle) -> I::Wide { + let radius = Self::radius(style); + let cap_padding = |cap: &IntLineCap| match cap { + IntLineCap::Square => radius.to_wide() * I::Wide::TWO, + IntLineCap::Custom(points) => points + .iter() + .map(|p| { + let d = + i_float::int::vector::IntVector::::new(p.x.to_wide(), p.y.to_wide()).sqr_length(); + let root = d.isqrt(); + I::Wide::from_uint(root) + + if root * root < d { + I::Wide::ONE + } else { + I::Wide::ZERO + } + }) + .max() + .unwrap_or(I::Wide::ZERO), + _ => radius.to_wide(), + }; + Join::padding(style.join, radius) + .max(cap_padding(&style.start_cap)) + .max(cap_padding(&style.end_cap)) + } + pub(super) fn new(style: &IntStrokeStyle) -> Self { + Self { + radius: Self::radius(style), + join: Join::new(style.join), + start: Cap::new(&style.start_cap), + end: Cap::new(&style.end_cap), + sections: Vec::new(), + } + } + pub(super) fn build( + &mut self, + path: &[IntPoint], + closed: bool, + segments: &mut Vec>, + ) { + self.sections.clear(); + if self.radius <= I::ONE { + return; + } + let Some(&first) = path.first() else { + return; + }; + let mut previous = first; + for &next in &path[1..] { + if previous != next { + self.sections.push(Section::new(previous, next, self.radius)); + previous = next; + } + } + if closed && previous != first { + self.sections.push(Section::new(previous, first, self.radius)); + } + if self.sections.is_empty() { + return; + } + for s in &self.sections { + s.add(segments); + } + for i in 1..self.sections.len() { + self.add_join(self.sections[i - 1], self.sections[i], segments); + } + let first = self.sections[0]; + let last = *self.sections.last().unwrap(); + if closed { + self.add_join(last, first, segments); + } else { + let backward = crate::mesh::int::math::direction(first.a - first.b).unwrap(); + self.start.add( + first.a, + first.a_left, + first.a_right, + backward, + self.radius, + segments, + ); + self.end + .add(last.b, last.b_right, last.b_left, last.dir, self.radius, segments); + } + } + fn add_join(&mut self, a: Section, b: Section, segments: &mut Vec>) { + let cross = (a.b - a.a).cross_product(b.b - b.a); + let (from, to, incoming, outgoing) = if cross >= I::Wide::ZERO { + (a.b_right, b.a_right, a.dir, b.dir) + } else { + ( + b.a_left, + a.b_left, + crate::mesh::int::math::direction(b.a - b.b).unwrap(), + crate::mesh::int::math::direction(a.a - a.b).unwrap(), + ) + }; + if cross >= I::Wide::ZERO { + segments.push_non_degenerate(b.a_left, a.b_left); + } else { + segments.push_non_degenerate(a.b_right, b.a_right); + } + self.join.add( + a.b, + from, + to, + incoming, + outgoing, + self.radius, + ArcDirection::Counterclockwise, + segments, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mesh::int::{arc::ArcOptions, style::IntLineJoin}; + use i_float::int::angle::Angle; + + #[test] + fn joins_and_caps_skip_collapsed_edges() { + for width in [0, 1, 2, 4, 1024] { + let radius = (width + 1) / 2; + for cap in [ + IntLineCap::Butt, + IntLineCap::Square, + IntLineCap::Round(ArcOptions::default()), + IntLineCap::Custom( + alloc::vec![ + IntPoint::new(0, -radius), + IntPoint::new(0, -radius), + IntPoint::new(radius, 0), + IntPoint::new(0, radius) + ] + .into(), + ), + ] { + for join in [ + IntLineJoin::Bevel, + IntLineJoin::Miter(Angle::from_bits(1 << 26)), + IntLineJoin::Miter(Angle::from_bits(2_000_000_000)), + IntLineJoin::Round(ArcOptions::default()), + ] { + let style = IntStrokeStyle::new(width) + .start_cap(cap.clone()) + .end_cap(cap.clone()) + .line_join(join); + let mut builder = StrokeBuilder::new(&style); + for closed in [false, true] { + for end in [IntPoint::new(10, 10), IntPoint::new(0, 0), IntPoint::new(20, 1)] { + let path = [ + IntPoint::new(0, 0), + IntPoint::new(0, 0), + IntPoint::new(10, 0), + end, + ]; + let mut segments = Vec::new(); + builder.build(&path, closed, &mut segments); + assert!(segments.iter().all(|s| s.x_segment.a < s.x_segment.b)); + if width >= 4 { + assert!(!segments.is_empty()); + } + } + } + } + } + } + } +} diff --git a/iOverlay/src/mesh/int/stroke/mod.rs b/iOverlay/src/mesh/int/stroke/mod.rs new file mode 100644 index 00000000..cb964e34 --- /dev/null +++ b/iOverlay/src/mesh/int/stroke/mod.rs @@ -0,0 +1,2 @@ +mod builder; +pub mod offset; diff --git a/iOverlay/src/mesh/int/stroke/offset.rs b/iOverlay/src/mesh/int/stroke/offset.rs new file mode 100644 index 00000000..07be5cc9 --- /dev/null +++ b/iOverlay/src/mesh/int/stroke/offset.rs @@ -0,0 +1,99 @@ +use super::builder::StrokeBuilder; +use crate::core::{ + fill_rule::FillRule, + integer::OverlayInt, + overlay::{IntOverlayOptions, Overlay}, + overlay_rule::OverlayRule, +}; +use crate::mesh::int::outline::offset::IntOutlineError; +use crate::mesh::int::style::IntStrokeStyle; +use alloc::vec::Vec; +use i_float::int::rect::IntRect; +use i_shape::{ + flat::buffer::FlatContoursBuffer, int::shape::IntShapes, source::int::resource::IntShapeResource, +}; + +pub type IntStrokeError = IntOutlineError; + +/// Strokes integer paths with radius `ceil(max(width, 0) / 2)`. +/// Radii at most one produce no geometry. +/// Coordinates, caps and temporary joins must stay in the engine's safe range. +/// Validation is optional in release and asserted in debug builds. +/// +/// ``` +/// use i_float::int::point::IntPoint; +/// use i_overlay::mesh::int::{stroke::offset::IntStrokeOffset, style::IntStrokeStyle}; +/// let path = [IntPoint::new(0, 0), IntPoint::new(8192, 0)]; +/// let style = IntStrokeStyle::new(2048); +/// path.validate_stroke(&style).unwrap(); +/// assert_eq!(path.stroke(&style, false).unwrap().len(), 1); +/// ``` +pub trait IntStrokeOffset: IntShapeResource { + fn validate_stroke(&self, style: &IntStrokeStyle) -> Result<(), IntStrokeError> { + if let Some(rect) = IntRect::with_iter(self.iter_paths().flatten()) { + if !crate::mesh::int::bounds::expanded_is_safe(rect, StrokeBuilder::padding(style)) { + return Err(IntStrokeError::CoordinateOutOfRange); + } + } + Ok(()) + } + fn stroke(&self, style: &IntStrokeStyle, closed: bool) -> Result, IntStrokeError> { + self.stroke_custom(style, closed, Default::default()) + } + fn stroke_into( + &self, + style: &IntStrokeStyle, + closed: bool, + output: &mut FlatContoursBuffer, + ) -> Result<(), IntStrokeError> { + self.stroke_custom_into(style, closed, Default::default(), output) + } + fn stroke_custom( + &self, + style: &IntStrokeStyle, + closed: bool, + options: IntOverlayOptions, + ) -> Result, IntStrokeError> { + Ok(self + .build_stroke_overlay(style, closed, options) + .overlay(OverlayRule::Subject, FillRule::Positive)) + } + fn stroke_custom_into( + &self, + style: &IntStrokeStyle, + closed: bool, + options: IntOverlayOptions, + output: &mut FlatContoursBuffer, + ) -> Result<(), IntStrokeError> { + self.build_stroke_overlay(style, closed, options).overlay_into( + OverlayRule::Subject, + FillRule::Positive, + output, + ); + Ok(()) + } +} +impl + ?Sized> IntStrokeOffset for S {} + +trait BuildStrokeOverlay: IntStrokeOffset { + fn build_stroke_overlay( + &self, + style: &IntStrokeStyle, + closed: bool, + options: IntOverlayOptions, + ) -> Overlay { + debug_assert!( + self.validate_stroke(style).is_ok(), + "stroke bounds exceed the safe coordinate range" + ); + let mut builder = StrokeBuilder::new(style); + let mut segments = Vec::new(); + for path in self.iter_paths() { + builder.build(path, closed, &mut segments); + } + let mut overlay = Overlay::with_segments(segments); + overlay.options = options; + overlay + } +} +impl + ?Sized> BuildStrokeOverlay for S {} diff --git a/iOverlay/src/mesh/int/style.rs b/iOverlay/src/mesh/int/style.rs index ce1af8e3..14bbfcf6 100644 --- a/iOverlay/src/mesh/int/style.rs +++ b/iOverlay/src/mesh/int/style.rs @@ -1,3 +1,5 @@ +use super::arc::ArcOptions; +use i_float::int::angle::Angle; use i_float::int::number::int::IntNumber; /// Join styles for integer mesh operations. @@ -5,12 +7,11 @@ use i_float::int::number::int::IntNumber; pub enum IntLineJoin { #[default] Bevel, - /// Placeholder until integer angles and miter construction are implemented. - /// Requesting this join returns `IntOutlineError::UnsupportedJoin`. - Miter, - /// Placeholder until integer angles and arc construction are implemented. - /// Requesting this join returns `IntOutlineError::UnsupportedJoin`. - Round, + /// Clipped miter, limited by the minimum interior angle. + /// The angle is clamped to 0.01*pi..=0.99*pi. + Miter(Angle), + /// Rounded join with reusable integer rotation settings. + Round(ArcOptions), } /// Signed offsets in input coordinate units. Positive values expand the filled @@ -53,3 +54,49 @@ impl IntOutlineStyle { self } } + +/// End caps for integer strokes. Custom points are local displacements in input +/// coordinate units, with x pointing outward and y pointing to its left. They +/// are already scaled to the desired size; no fractional template scale is used. +#[derive(Debug, Clone)] +pub enum IntLineCap { + Butt, + Square, + Round(ArcOptions), + Custom(alloc::rc::Rc<[i_float::int::point::IntPoint]>), +} + +#[derive(Debug, Clone)] +pub struct IntStrokeStyle { + pub width: I, + pub start_cap: IntLineCap, + pub end_cap: IntLineCap, + pub join: IntLineJoin, +} + +impl IntStrokeStyle { + pub fn new(width: I) -> Self { + Self { + width, + start_cap: IntLineCap::Butt, + end_cap: IntLineCap::Butt, + join: IntLineJoin::Bevel, + } + } + pub fn width(mut self, width: I) -> Self { + self.width = width; + self + } + pub fn start_cap(mut self, cap: IntLineCap) -> Self { + self.start_cap = cap; + self + } + pub fn end_cap(mut self, cap: IntLineCap) -> Self { + self.end_cap = cap; + self + } + pub fn line_join(mut self, join: IntLineJoin) -> Self { + self.join = join; + self + } +} diff --git a/iOverlay/src/mesh/int/variable_stroke/builder.rs b/iOverlay/src/mesh/int/variable_stroke/builder.rs new file mode 100644 index 00000000..37471098 --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/builder.rs @@ -0,0 +1,581 @@ +#[cfg(feature = "variable_stroke_debug")] +use super::debug::{IntVariableStrokeDebugEdge, VariableStrokeDebugEdgeKind}; +use super::{ + section::{RadiusTrend, Section}, + style::{IntStrokeVertex, IntVariableStrokeStyle}, +}; +use crate::mesh::{ + int::{ + arc::{ArcBuilder, ArcDirection}, + math::{direction, point, scaled_point}, + }, + subject::SubjectSegments, +}; +use crate::segm::{boolean::ShapeCountBoolean, segment::Segment}; +use alloc::vec::Vec; +use i_float::int::{ + angle::Angle, + number::{int::IntNumber, uint::UIntNumber, wide_int::WideIntNumber}, + point::IntPoint, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Cap { + Butt, + Round, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ArcSweep { + Minor, + Major, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SubSegment { + start: usize, + end: usize, + start_cap: Cap, + end_cap: Cap, +} + +pub(super) struct VariableStrokeBuilder { + arc: ArcBuilder, +} + +impl VariableStrokeBuilder { + pub(super) fn new(style: IntVariableStrokeStyle) -> Self { + Self { + arc: ArcBuilder::new(style.arc), + } + } + + pub(super) fn build( + &mut self, + path: &[IntStrokeVertex], + segments: &mut Vec>, + ) { + if path.is_empty() { + return; + } + + let subsegments = Self::find_subsegments(path); + let mut output = SegmentBuilder { + arc: &mut self.arc, + segments, + #[cfg(feature = "variable_stroke_debug")] + debug_edges: None, + #[cfg(feature = "variable_stroke_debug")] + debug_path_index: 0, + }; + + for subsegment in subsegments.iter() { + Self::add_subsegment(subsegment, path, &mut output); + } + } + + #[cfg(feature = "variable_stroke_debug")] + pub(super) fn build_debug( + &mut self, + path: &[IntStrokeVertex], + path_index: usize, + segments: &mut Vec>, + debug_edges: &mut Vec>, + ) { + if path.is_empty() { + return; + } + + let subsegments = Self::find_subsegments(path); + let mut output = SegmentBuilder { + arc: &mut self.arc, + segments, + debug_edges: Some(debug_edges), + debug_path_index: path_index, + }; + + for subsegment in subsegments.iter() { + Self::add_subsegment(subsegment, path, &mut output); + } + } + + fn add_subsegment(subsegment: &SubSegment, path: &[IntStrokeVertex], output: &mut SegmentBuilder) { + if subsegment.start == subsegment.end { + if subsegment.start_cap != Cap::Butt || subsegment.end_cap != Cap::Butt { + let vertex = &path[subsegment.start]; + output.add_circle( + &vertex.point, + vertex.radius(), + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::CircleArc, + ); + } + return; + } + + let mut sections = (subsegment.start..subsegment.end) + .filter_map(|index| Section::try_new(&path[index], &path[index + 1])); + let Some(mut previous) = sections.next() else { + return; + }; + + output.add_section(&previous); + output.add_start_cap(&previous, subsegment.start_cap); + + for section in sections { + output.add_section(§ion); + output.add_join(&previous, §ion); + previous = section; + } + + output.add_end_cap(&previous, subsegment.end_cap); + } + + fn find_subsegments(path: &[IntStrokeVertex]) -> Vec { + if path.is_empty() { + return Vec::new(); + } + + let mut result = Vec::new(); + let mut start = 0; + let mut start_cap = Cap::Round; + let mut final_end_cap = Cap::Round; + + for (index, pair) in path.windows(2).enumerate() { + final_end_cap = Cap::Round; + + if let Some((end_cap, next_start_cap)) = Self::break_caps(&pair[0], &pair[1]) { + result.push(SubSegment { + start, + end: index, + start_cap, + end_cap, + }); + + start = index + 1; + start_cap = next_start_cap; + continue; + } + + if index > 0 && Self::circle_is_covered_by_section(&path[index - 1], &pair[0], &pair[1]) { + result.push(SubSegment { + start, + end: index, + start_cap, + end_cap: Cap::Round, + }); + + start = index; + start_cap = Cap::Butt; + final_end_cap = Cap::Butt; + } + } + + result.push(SubSegment { + start, + end: path.len() - 1, + start_cap, + end_cap: final_end_cap, + }); + result + } + + fn break_caps(a: &IntStrokeVertex, b: &IntStrokeVertex) -> Option<(Cap, Cap)> { + let int_a = a.point; + let int_b = b.point; + let a_radius = a.radius(); + let b_radius = b.radius(); + let radius_delta = a_radius.to_wide() - b_radius.to_wide(); + let distance_sqr = (int_b - int_a).sqr_length(); + + if (radius_delta * radius_delta).to_uint() < distance_sqr { + return None; + } + + if a_radius >= b_radius { + Some((Cap::Round, Cap::Butt)) + } else { + Some((Cap::Butt, Cap::Round)) + } + } + + fn circle_is_covered_by_section( + a: &IntStrokeVertex, + b: &IntStrokeVertex, + c: &IntStrokeVertex, + ) -> bool { + let a_radius = a.radius(); + let b_radius = b.radius(); + let c_radius = c.radius(); + if a_radius.max(b_radius) <= c_radius { + return false; + } + + let Some(section) = Section::try_new(a, b) else { + return false; + }; + + let points = [section.a_left, section.b_left, section.b_right, section.a_right]; + let center = c.point; + let radius = c_radius.to_wide().to_uint(); + let first_edge = points[1] - points[0]; + let orientation = first_edge.cross_product(points[2] - points[1]); + if orientation == I::Wide::ZERO { + return false; + } + + for index in 0..points.len() { + let a = points[index]; + let b = points[(index + 1) % points.len()]; + let edge = b - a; + let side = edge.cross_product(center - a); + let interior_distance = if orientation > I::Wide::ZERO { side } else { -side }; + if interior_distance < I::Wide::ZERO { + return false; + } + + let length_sqr = edge.sqr_length(); + let mut length = length_sqr.isqrt(); + if length * length < length_sqr { + length += I::WideUInt::ONE; + } + if interior_distance.to_uint() < radius * length { + return false; + } + } + + true + } +} + +struct SegmentBuilder<'a, I: IntNumber> { + arc: &'a mut ArcBuilder, + segments: &'a mut Vec>, + #[cfg(feature = "variable_stroke_debug")] + debug_edges: Option<&'a mut Vec>>, + #[cfg(feature = "variable_stroke_debug")] + debug_path_index: usize, +} + +impl SegmentBuilder<'_, I> { + fn add_circle( + &mut self, + center: &IntPoint, + radius: I, + #[cfg(feature = "variable_stroke_debug")] kind: VariableStrokeDebugEdgeKind, + ) { + if radius <= I::ONE { + return; + } + let right = point(*center, radius.to_wide(), I::Wide::ZERO); + let left = point(*center, -radius.to_wide(), I::Wide::ZERO); + self.arc_edges( + center, + &right, + &left, + radius, + #[cfg(feature = "variable_stroke_debug")] + kind, + ); + self.arc_edges( + center, + &left, + &right, + radius, + #[cfg(feature = "variable_stroke_debug")] + kind, + ); + } + + #[inline] + fn add_section(&mut self, section: &Section) { + self.add_edge( + §ion.b_left, + §ion.a_left, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::SectionBoundary, + ); + self.add_edge( + §ion.a_right, + §ion.b_right, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::SectionBoundary, + ); + } + + fn add_join(&mut self, prev: &Section, next: &Section) -> usize { + let prev_center = prev.b; + let next_center = next.a; + if prev_center != next_center { + // A non-drawable section between these sections was filtered out. They belong to + // separate chains, so close both chains instead of building an arc between centers. + self.add_end_cap(prev, Cap::Butt); + self.add_start_cap(next, Cap::Butt); + return 0; + } + + let prev_a_left = prev.a_left; + let prev_b_left = prev.b_left; + let prev_a_right = prev.a_right; + let prev_b_right = prev.b_right; + let next_a_left = next.a_left; + let next_b_left = next.b_left; + let next_a_right = next.a_right; + let next_b_right = next.b_right; + + let prev_left = prev_b_left - prev_a_left; + let prev_right = prev_b_right - prev_a_right; + let next_left = next_b_left - next_a_left; + let next_right = next_b_right - next_a_right; + + let mut arc_count = 0; + let left_cross = next_left.cross_product(prev_left); + + let right_cross = prev_right.cross_product(next_right); + + let prev_a = prev.a; + let prev_b = prev_center; + let next_a = next.a; + let next_b = next.b; + + let prev_middle = prev_b - prev_a; + let next_middle = next_b - next_a; + + let middle_cross = prev_middle.cross_product(next_middle); + + let left_arc = left_cross > I::Wide::ZERO || middle_cross < I::Wide::ZERO; + let right_arc = right_cross > I::Wide::ZERO || middle_cross >= I::Wide::ZERO; + + if left_arc { + arc_count += self.add_arc_ccw( + &prev.b, + &next.a_left, + &prev.b_left, + ArcSweep::Minor, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::JoinArc, + ) as usize; + } else { + self.add_edge( + &next.a_left, + &prev.b_left, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::JoinClosure, + ); + } + + if right_arc { + arc_count += self.add_arc_ccw( + &prev.b, + &prev.b_right, + &next.a_right, + ArcSweep::Major, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::JoinArc, + ) as usize; + } else { + self.add_edge( + &prev.b_right, + &next.a_right, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::JoinClosure, + ); + } + + arc_count + } + + fn add_start_cap(&mut self, section: &Section, cap: Cap) { + match cap { + Cap::Butt => self.add_edge( + §ion.a_left, + §ion.a_right, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::CapClosure, + ), + Cap::Round => { + let sweep = if section.radius_trend == RadiusTrend::Decreasing { + ArcSweep::Major + } else { + ArcSweep::Minor + }; + self.add_arc_ccw( + §ion.a, + §ion.a_left, + §ion.a_right, + sweep, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::CapArc, + ); + } + } + } + + fn add_end_cap(&mut self, section: &Section, cap: Cap) { + match cap { + Cap::Butt => self.add_edge( + §ion.b_right, + §ion.b_left, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::CapClosure, + ), + Cap::Round => { + let sweep = if section.radius_trend == RadiusTrend::Increasing { + ArcSweep::Major + } else { + ArcSweep::Minor + }; + self.add_arc_ccw( + §ion.b, + §ion.b_right, + §ion.b_left, + sweep, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::CapArc, + ); + } + } + } + + fn arc_sweep_ccw( + &self, + center: &IntPoint, + from: &IntPoint, + to: &IntPoint, + aligned_sweep: ArcSweep, + ) -> ArcSweep { + let center = *center; + let from_vector = *from - center; + let to_vector = *to - center; + let cross = from_vector.cross_product(to_vector); + + if cross > I::Wide::ZERO { + ArcSweep::Minor + } else if cross < I::Wide::ZERO { + ArcSweep::Major + } else if from_vector.dot_product(to_vector) < I::Wide::ZERO { + // Both choices describe the same half-circle. + ArcSweep::Minor + } else { + // Coincident directions can mean either a collapsed minor arc or a full major arc. + aligned_sweep + } + } + + fn add_arc_ccw( + &mut self, + center: &IntPoint, + from: &IntPoint, + to: &IntPoint, + aligned_sweep: ArcSweep, + #[cfg(feature = "variable_stroke_debug")] edge_kind: VariableStrokeDebugEdgeKind, + ) -> bool { + let sweep = self.arc_sweep_ccw(center, from, to, aligned_sweep); + if sweep == ArcSweep::Minor && *from == *to { + return false; + } + + let Some(from_unit) = direction(*from - *center) else { + return false; + }; + let Some(to_unit) = direction(*to - *center) else { + return false; + }; + let from_vector = *from - *center; + let radius = I::from_uint(from_vector.sqr_length().isqrt()); + if Angle::between(from_unit, to_unit).bits() == 0 && sweep == ArcSweep::Major { + self.add_circle( + center, + radius, + #[cfg(feature = "variable_stroke_debug")] + edge_kind, + ); + self.add_edge( + from, + to, + #[cfg(feature = "variable_stroke_debug")] + edge_kind, + ); + } else { + self.arc_edges( + center, + from, + to, + radius, + #[cfg(feature = "variable_stroke_debug")] + edge_kind, + ); + } + true + } + + fn arc_edges( + &mut self, + center: &IntPoint, + from: &IntPoint, + to: &IntPoint, + radius: I, + #[cfg(feature = "variable_stroke_debug")] kind: VariableStrokeDebugEdgeKind, + ) { + let Some(a) = direction(*from - *center) else { + return; + }; + let Some(b) = direction(*to - *center) else { + return; + }; + let directions = self.arc.build(a, b, ArcDirection::Counterclockwise); + let mut previous = *from; + // Split borrows: the reusable arc buffer stays borrowed while emitting. + for &dir in directions { + let next = scaled_point(*center, dir, radius); + if previous != next { + #[cfg(feature = "variable_stroke_debug")] + if let Some(edges) = self.debug_edges.as_mut() { + edges.push(IntVariableStrokeDebugEdge { + a: previous, + b: next, + kind, + path_index: self.debug_path_index, + order: edges.len(), + }); + } + self.segments.push_non_degenerate(previous, next); + } + previous = next; + } + self.add_edge( + &previous, + to, + #[cfg(feature = "variable_stroke_debug")] + kind, + ); + } + + #[inline] + fn add_edge( + &mut self, + a: &IntPoint, + b: &IntPoint, + #[cfg(feature = "variable_stroke_debug")] kind: VariableStrokeDebugEdgeKind, + ) { + let a = *a; + let b = *b; + if a != b { + #[cfg(feature = "variable_stroke_debug")] + if let Some(debug_edges) = self.debug_edges.as_mut() { + debug_edges.push(IntVariableStrokeDebugEdge { + a, + b, + kind, + path_index: self.debug_path_index, + order: debug_edges.len(), + }); + } + self.segments.push(Segment::subject(a, b)); + } + } +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/iOverlay/src/mesh/int/variable_stroke/debug.rs b/iOverlay/src/mesh/int/variable_stroke/debug.rs new file mode 100644 index 00000000..b673aa18 --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/debug.rs @@ -0,0 +1,37 @@ +use i_float::int::{number::int::IntNumber, point::IntPoint}; + +/// The variable-stroke construction operation that emitted a raw pre-overlay edge. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VariableStrokeDebugEdgeKind { + /// One of the two tangent boundaries of a drawable centerline section. + SectionBoundary, + /// One chord of a round join. + JoinArc, + /// A straight edge closing the gap between adjacent sections. + JoinClosure, + /// One chord of a round end cap. + CapArc, + /// A butt edge closing an end cap. + CapClosure, + /// One chord of a circle emitted for an isolated drawable vertex. + CircleArc, +} + +/// One directed edge submitted by `SegmentBuilder` before overlay processing. +#[derive(Debug, Clone, Copy)] +pub struct IntVariableStrokeDebugEdge { + pub a: IntPoint, + pub b: IntPoint, + pub kind: VariableStrokeDebugEdgeKind, + /// Index of the source variable-width path. + pub path_index: usize, + /// Global insertion order across all source paths. + pub order: usize, +} + +/// The raw construction edges and the regular post-overlay stroke result. +#[derive(Debug, Clone)] +pub struct IntVariableStrokeDebugResult { + pub edges: alloc::vec::Vec>, + pub shapes: i_shape::int::shape::IntShapes, +} diff --git a/iOverlay/src/mesh/int/variable_stroke/mod.rs b/iOverlay/src/mesh/int/variable_stroke/mod.rs new file mode 100644 index 00000000..af1e74f8 --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/mod.rs @@ -0,0 +1,10 @@ +mod builder; +#[cfg(feature = "variable_stroke_debug")] +pub mod debug; +mod resource; +mod section; +mod style; +pub use resource::IntVariableStrokeSource; +pub use style::{IntStrokeVertex, IntVariableStrokeStyle}; + +pub mod offset; diff --git a/iOverlay/src/mesh/int/variable_stroke/offset.rs b/iOverlay/src/mesh/int/variable_stroke/offset.rs new file mode 100644 index 00000000..f2a7aeb2 --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/offset.rs @@ -0,0 +1,120 @@ +use super::{IntVariableStrokeSource, IntVariableStrokeStyle, builder::VariableStrokeBuilder}; +use crate::core::{ + fill_rule::FillRule, + integer::OverlayInt, + overlay::{IntOverlayOptions, Overlay}, + overlay_rule::OverlayRule, +}; +use crate::mesh::int::outline::offset::IntOutlineError; +use alloc::vec::Vec; +use i_float::int::rect::IntRect; +use i_shape::{flat::buffer::FlatContoursBuffer, int::shape::IntShapes}; + +pub type IntVariableStrokeError = IntOutlineError; + +/// Integer variable-width strokes with round caps and joins. Widths and +/// coordinates are in input units. Call validation separately when necessary; +/// release construction trusts the coordinate-range precondition. +/// +/// ``` +/// use i_float::int::point::IntPoint; +/// use i_overlay::mesh::int::variable_stroke::{IntStrokeVertex, IntVariableStrokeStyle, +/// offset::IntVariableStrokeOffset}; +/// let path = [IntStrokeVertex::new(IntPoint::new(0, 0), 2048), +/// IntStrokeVertex::new(IntPoint::new(8192, 0), 4096)]; +/// path.validate_variable_stroke().unwrap(); +/// assert_eq!(path.variable_stroke(IntVariableStrokeStyle::new()).unwrap().len(), 1); +/// ``` +pub trait IntVariableStrokeOffset: IntVariableStrokeSource { + fn validate_variable_stroke(&self) -> Result<(), IntVariableStrokeError> { + let radius = self + .iter_variable_paths() + .flatten() + .map(|v| v.radius()) + .max() + .unwrap_or(I::ZERO); + if let Some(rect) = IntRect::with_iter(self.iter_variable_paths().flatten().map(|v| &v.point)) { + if !crate::mesh::int::bounds::expanded_is_safe(rect, radius.to_wide()) { + return Err(IntVariableStrokeError::CoordinateOutOfRange); + } + } + Ok(()) + } + fn variable_stroke(&self, style: IntVariableStrokeStyle) -> Result, IntVariableStrokeError> { + self.variable_stroke_custom(style, Default::default()) + } + fn variable_stroke_into( + &self, + style: IntVariableStrokeStyle, + output: &mut FlatContoursBuffer, + ) -> Result<(), IntVariableStrokeError> { + self.variable_stroke_custom_into(style, Default::default(), output) + } + fn variable_stroke_custom( + &self, + style: IntVariableStrokeStyle, + options: IntOverlayOptions, + ) -> Result, IntVariableStrokeError> { + Ok(self + .build_variable_overlay(style, options) + .overlay(OverlayRule::Subject, FillRule::Positive)) + } + fn variable_stroke_custom_into( + &self, + style: IntVariableStrokeStyle, + options: IntOverlayOptions, + output: &mut FlatContoursBuffer, + ) -> Result<(), IntVariableStrokeError> { + self.build_variable_overlay(style, options).overlay_into( + OverlayRule::Subject, + FillRule::Positive, + output, + ); + Ok(()) + } + #[cfg(feature = "variable_stroke_debug")] + fn variable_stroke_debug( + &self, + style: IntVariableStrokeStyle, + options: IntOverlayOptions, + ) -> Result, IntVariableStrokeError> { + debug_assert!( + self.validate_variable_stroke().is_ok(), + "variable stroke exceeds coordinate range" + ); + let mut builder = VariableStrokeBuilder::new(style); + let mut segments = Vec::new(); + let mut edges = Vec::new(); + for (index, path) in self.iter_variable_paths().enumerate() { + builder.build_debug(path, index, &mut segments, &mut edges); + } + let mut overlay = Overlay::with_segments(segments); + overlay.options = options; + Ok(super::debug::IntVariableStrokeDebugResult { + edges, + shapes: overlay.overlay(OverlayRule::Subject, FillRule::Positive), + }) + } +} +impl + ?Sized> IntVariableStrokeOffset for S {} +trait BuildVariableOverlay: IntVariableStrokeOffset { + fn build_variable_overlay( + &self, + style: IntVariableStrokeStyle, + options: IntOverlayOptions, + ) -> Overlay { + debug_assert!( + self.validate_variable_stroke().is_ok(), + "variable stroke exceeds coordinate range" + ); + let mut builder = VariableStrokeBuilder::new(style); + let mut segments = Vec::new(); + for path in self.iter_variable_paths() { + builder.build(path, &mut segments); + } + let mut overlay = Overlay::with_segments(segments); + overlay.options = options; + overlay + } +} +impl + ?Sized> BuildVariableOverlay for S {} diff --git a/iOverlay/src/mesh/int/variable_stroke/resource.rs b/iOverlay/src/mesh/int/variable_stroke/resource.rs new file mode 100644 index 00000000..b2ad933a --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/resource.rs @@ -0,0 +1,185 @@ +use crate::mesh::int::variable_stroke::style::IntStrokeVertex; +use alloc::vec::Vec; +use i_float::int::number::int::IntNumber; + +pub trait IntVariableStrokeSource +where + I: IntNumber, +{ + type ResourceIter<'a>: Iterator]> + where + I: 'a, + Self: 'a; + + fn iter_variable_paths(&self) -> Self::ResourceIter<'_>; +} + +pub struct ContourResourceIterator<'a, I: IntNumber> { + slice: &'a [IntStrokeVertex], + finished: bool, +} + +impl<'a, I: IntNumber> ContourResourceIterator<'a, I> { + #[inline] + fn with_slice(slice: &'a [IntStrokeVertex]) -> Self { + Self { + slice, + finished: false, + } + } +} + +impl<'a, I: IntNumber> Iterator for ContourResourceIterator<'a, I> { + type Item = &'a [IntStrokeVertex]; + + #[inline] + fn next(&mut self) -> Option { + if self.finished { + return None; + } + self.finished = true; + Some(self.slice) + } + + #[inline] + fn count(self) -> usize { + usize::from(!self.finished) + } +} + +impl IntVariableStrokeSource for [IntStrokeVertex] { + type ResourceIter<'a> + = ContourResourceIterator<'a, I> + where + I: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'_> { + ContourResourceIterator::with_slice(self) + } +} + +impl IntVariableStrokeSource for [IntStrokeVertex; N] { + type ResourceIter<'a> + = ContourResourceIterator<'a, I> + where + I: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'_> { + ContourResourceIterator::with_slice(self) + } +} + +impl IntVariableStrokeSource for Vec> { + type ResourceIter<'a> + = ContourResourceIterator<'a, I> + where + I: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'_> { + ContourResourceIterator::with_slice(self.as_slice()) + } +} + +impl<'b, I: IntNumber> IntVariableStrokeSource for &'b [IntStrokeVertex] { + type ResourceIter<'a> + = ContourResourceIterator<'a, I> + where + I: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'b> { + ContourResourceIterator::with_slice(self) + } +} + +pub struct ShapeResourceIterator<'a, I: IntNumber> { + slice: &'a [Vec>], + index: usize, +} + +impl<'a, I: IntNumber> Iterator for ShapeResourceIterator<'a, I> { + type Item = &'a [IntStrokeVertex]; + + #[inline] + fn next(&mut self) -> Option { + let path = self.slice.get(self.index)?; + self.index += 1; + Some(path.as_slice()) + } + + #[inline] + fn count(self) -> usize { + self.slice.len() - self.index + } +} + +impl IntVariableStrokeSource for [Vec>] { + type ResourceIter<'a> + = ShapeResourceIterator<'a, I> + where + I: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'_> { + ShapeResourceIterator { + slice: self, + index: 0, + } + } +} + +impl IntVariableStrokeSource for [Vec>; N] { + type ResourceIter<'a> + = ShapeResourceIterator<'a, I> + where + I: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'_> { + ShapeResourceIterator { + slice: self, + index: 0, + } + } +} + +impl IntVariableStrokeSource for Vec>> { + type ResourceIter<'a> + = ShapeResourceIterator<'a, I> + where + I: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'_> { + ShapeResourceIterator { + slice: self.as_slice(), + index: 0, + } + } +} + +impl<'b, I: IntNumber> IntVariableStrokeSource for &'b [Vec>] { + type ResourceIter<'a> + = ShapeResourceIterator<'a, I> + where + I: 'a, + Self: 'a; + + #[inline] + fn iter_variable_paths(&self) -> Self::ResourceIter<'b> { + ShapeResourceIterator { + slice: self, + index: 0, + } + } +} diff --git a/iOverlay/src/mesh/int/variable_stroke/section.rs b/iOverlay/src/mesh/int/variable_stroke/section.rs new file mode 100644 index 00000000..919d6384 --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/section.rs @@ -0,0 +1,72 @@ +use super::style::IntStrokeVertex; +use crate::mesh::int::math::{mul_div, point}; +use i_float::int::{ + number::{int::IntNumber, uint::UIntNumber, wide_int::WideIntNumber}, + point::IntPoint, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RadiusTrend { + Decreasing, + Constant, + Increasing, +} +#[derive(Clone, Copy)] +pub(super) struct Section { + pub(super) a: IntPoint, + pub(super) b: IntPoint, + pub(super) a_left: IntPoint, + pub(super) a_right: IntPoint, + pub(super) b_left: IntPoint, + pub(super) b_right: IntPoint, + pub(super) radius_trend: RadiusTrend, +} +impl Section { + pub(super) fn try_new(a: &IntStrokeVertex, b: &IntStrokeVertex) -> Option { + let ra = a.radius(); + let rb = b.radius(); + if a.point == b.point || ra.max(rb) <= I::ONE { + return None; + } + let delta = ra.to_wide() - rb.to_wide(); + let v = b.point - a.point; + let sq = v.sqr_length(); + if (delta * delta).to_uint() >= sq { + return None; + } + // Keep fractional bits of sqrt(length² - delta²). Taking an unscaled + // integer root would noticeably shrink wide strokes on short sections. + // Using sq to choose the shift also bounds every numerator by sq << shift. + let shift = (sq.leading_zeros() - 1) / 2; + let tangent = I::Wide::from_uint(((sq - (delta * delta).to_uint()) << (2 * shift)).isqrt()); + let denominator = I::Wide::from_uint(sq << shift); + let dx = (delta * v.x) << shift; + let dy = (delta * v.y) << shift; + let left_x = dx - tangent * v.y; + let left_y = dy + tangent * v.x; + let right_x = dx + tangent * v.y; + let right_y = dy - tangent * v.x; + let contact = |p, r: I, x, y| { + point( + p, + mul_div::(r.to_wide(), x, denominator), + mul_div::(r.to_wide(), y, denominator), + ) + }; + Some(Self { + a: a.point, + b: b.point, + a_left: contact(a.point, ra, left_x, left_y), + a_right: contact(a.point, ra, right_x, right_y), + b_left: contact(b.point, rb, left_x, left_y), + b_right: contact(b.point, rb, right_x, right_y), + radius_trend: if ra < rb { + RadiusTrend::Increasing + } else if ra > rb { + RadiusTrend::Decreasing + } else { + RadiusTrend::Constant + }, + }) + } +} diff --git a/iOverlay/src/mesh/int/variable_stroke/style.rs b/iOverlay/src/mesh/int/variable_stroke/style.rs new file mode 100644 index 00000000..2fadba5d --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/style.rs @@ -0,0 +1,43 @@ +use crate::mesh::int::arc::ArcOptions; +use i_float::int::{ + number::{int::IntNumber, wide_int::WideIntNumber}, + point::IntPoint, +}; + +#[derive(Debug, Clone, Copy)] +pub struct IntStrokeVertex { + pub point: IntPoint, + pub width: I, +} +impl IntStrokeVertex { + pub fn new(point: IntPoint, width: I) -> Self { + Self { point, width } + } + pub(super) fn radius(&self) -> I { + I::from_wide((self.width.max(I::ZERO).to_wide() + I::Wide::ONE) / I::Wide::TWO) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct IntVariableStrokeStyle { + pub arc: ArcOptions, +} +impl Default for IntVariableStrokeStyle { + fn default() -> Self { + Self { + arc: ArcOptions { + max_step: i_float::int::angle::Angle::from_bits(68_356_528), + ..ArcOptions::default() + }, + } + } +} +impl IntVariableStrokeStyle { + pub fn new() -> Self { + Self::default() + } + pub fn arc(mut self, arc: ArcOptions) -> Self { + self.arc = arc; + self + } +} diff --git a/iOverlay/src/mesh/int/variable_stroke/tests.rs b/iOverlay/src/mesh/int/variable_stroke/tests.rs new file mode 100644 index 00000000..c12e655e --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/tests.rs @@ -0,0 +1,264 @@ +use super::*; +use crate::mesh::int::arc::ArcOptions; +use crate::mesh::int::variable_stroke::offset::IntVariableStrokeOffset; + +fn vertex(x: i32, y: i32, width: i32) -> IntStrokeVertex { + IntStrokeVertex::new(IntPoint::new(x, y), width) +} + +fn output<'a>( + arc: &'a mut ArcBuilder, + segments: &'a mut Vec>, +) -> SegmentBuilder<'a, i32> { + SegmentBuilder { + arc, + segments, + #[cfg(feature = "variable_stroke_debug")] + debug_edges: None, + #[cfg(feature = "variable_stroke_debug")] + debug_path_index: 0, + } +} + +#[test] +fn covered_break_closes_only_the_larger_side() { + for (small, large, end_cap, start_cap) in [ + (4000, 20000, Cap::Butt, Cap::Round), + (20000, 4000, Cap::Round, Cap::Butt), + ] { + let path = [ + vertex(-20000, 0, small), + vertex(0, 0, small), + vertex(2000, 0, large), + vertex(22000, 0, large), + ]; + let parts = VariableStrokeBuilder::find_subsegments(&path); + assert_eq!(parts.len(), 2); + assert_eq!((parts[0].start, parts[0].end, parts[0].end_cap), (0, 1, end_cap)); + assert_eq!( + (parts[1].start, parts[1].end, parts[1].start_cap), + (2, 3, start_cap) + ); + } +} + +#[test] +fn near_covered_sections_stay_connected() { + let path = [vertex(0, 0, 600), vertex(757, 386, 1800), vertex(1920, 712, 4200)]; + assert_eq!( + VariableStrokeBuilder::find_subsegments(&path), + [SubSegment { + start: 0, + end: 2, + start_cap: Cap::Round, + end_cap: Cap::Round + }] + ); +} + +#[test] +fn covered_zero_length_butt_section_emits_nothing() { + let path = [ + vertex(-2000, 0, 20000), + vertex(0, 0, 2000), + vertex(2000, 0, 20000), + ]; + let parts = VariableStrokeBuilder::find_subsegments(&path); + assert_eq!(parts.len(), 3); + assert_eq!( + parts[1], + SubSegment { + start: 1, + end: 1, + start_cap: Cap::Butt, + end_cap: Cap::Butt + } + ); + let mut arc = ArcBuilder::default(); + let mut segments = Vec::new(); + VariableStrokeBuilder::add_subsegment(&parts[1], &path, &mut output(&mut arc, &mut segments)); + assert!(segments.is_empty()); +} + +#[test] +fn coverage_requires_a_larger_circle() { + let c = vertex(50000, 0, 20000); + for (width, covered) in [(20000, false), (40000, true)] { + assert_eq!( + VariableStrokeBuilder::circle_is_covered_by_section( + &vertex(0, 0, width), + &vertex(100000, 0, width), + &c + ), + covered + ); + } +} + +#[test] +fn joins_preserve_contacts_and_select_all_exposed_arcs() { + let cases = [ + ( + [ + vertex(-10000, 0, 4000), + vertex(0, 0, 10000), + vertex(10000, 0, 4000), + ], + 2, + ), + ( + [ + vertex(-10000, 0, 4000), + vertex(0, 0, 4000), + vertex(0, 10000, 4000), + ], + 1, + ), + ( + [ + vertex(-86000, 2000, 10000), + vertex(100000, 0, 100000), + vertex(99000, -45000, 10000), + ], + 2, + ), + ( + [ + vertex(0, 0, 22000), + vertex(100000, 0, 220000), + vertex(100000, -100000, 22000), + ], + 2, + ), + ( + [ + vertex(0, 0, 8800), + vertex(100000, 0, 88000), + vertex(100000, -100000, 8800), + ], + 1, + ), + ]; + for (path, expected) in cases { + let prev = Section::try_new(&path[0], &path[1]).unwrap(); + let next = Section::try_new(&path[1], &path[2]).unwrap(); + let mut arc = ArcBuilder::new(IntVariableStrokeStyle::default().arc); + let mut segments = Vec::new(); + assert_eq!(output(&mut arc, &mut segments).add_join(&prev, &next), expected); + for p in [prev.b_left, prev.b_right, next.a_left, next.a_right] { + assert!( + segments.iter().any(|s| s.x_segment.a == p || s.x_segment.b == p), + "lost tangent contact {p:?}" + ); + } + assert_eq!( + path.variable_stroke(IntVariableStrokeStyle::default()) + .unwrap() + .len(), + 1 + ); + } +} + +#[test] +fn coarse_arc_preserves_exact_contacts_and_full_major_arc() { + let center = IntPoint::new(0, 0); + let from = IntPoint::new(10000, 0); + let to = IntPoint::new(9950, 998); + let mut arc = ArcBuilder::default(); + let mut segments = Vec::new(); + assert!(output(&mut arc, &mut segments).add_arc_ccw( + ¢er, + &from, + &to, + ArcSweep::Minor, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::JoinArc + )); + assert_eq!(segments.len(), 1); + let edge = segments[0].x_segment; + assert!((edge.a == from && edge.b == to) || (edge.b == from && edge.a == to)); + segments.clear(); + assert!(output(&mut arc, &mut segments).add_arc_ccw( + ¢er, + &from, + &from, + ArcSweep::Major, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::JoinArc + )); + assert!(segments.len() >= 8); +} + +#[test] +fn reversals_close_both_sections_without_an_interior_tooth() { + for sign in [-1, 1] { + let path = [ + vertex(-86000, 2000 * sign, 21800), + vertex(100000, 0, 218000), + vertex(-20700, -16030 * sign, 21800), + ]; + let parts = VariableStrokeBuilder::find_subsegments(&path); + assert_eq!(parts.len(), 2); + assert_eq!(parts[0].end_cap, Cap::Round); + assert_eq!((parts[1].start_cap, parts[1].end_cap), (Cap::Butt, Cap::Butt)); + let style = IntVariableStrokeStyle::new().arc(ArcOptions { + max_step: Angle::from_bits(512_673_957), + ..ArcOptions::default() + }); + let shapes = path.variable_stroke(style).unwrap(); + assert_eq!(shapes.len(), 1); + assert!(!shapes.iter().flatten().flatten().any(|p| { + let dx = i64::from(p.x) - 100000; + let dy = i64::from(p.y); + p.x > 20000 && p.y * sign < -70000 && dx * dx + dy * dy < 108500_i64.pow(2) + })); + } +} + +#[test] +fn duplicates_and_small_widths_never_emit_zero_length_edges() { + for width in [0, 1, 2, 4, 1024] { + for end in [(10, 10), (0, 0), (20, 0)] { + let path = [ + vertex(0, 0, width), + vertex(0, 0, width), + vertex(10, 0, 2 * width), + vertex(end.0, end.1, width), + ]; + let mut builder = VariableStrokeBuilder::new(IntVariableStrokeStyle::default()); + let mut segments = Vec::new(); + builder.build(&path, &mut segments); + assert!(segments.iter().all(|s| s.x_segment.a < s.x_segment.b)); + if width >= 4 { + assert!(!segments.is_empty()); + } + } + } +} + +#[test] +fn short_sections_keep_tangent_precision_at_large_widths() { + for (x, y) in [(1, 1), (2, 2), (3, 2), (1, 7)] { + for delta in [0, 1] { + let a = vertex(0, 0, 8192); + let b = vertex(x, y, 8192 - 2 * delta); + let section = Section::try_new(&a, &b).unwrap(); + let sq = f64::from(x * x + y * y); + let tangent = (sq - f64::from(delta * delta)).sqrt(); + for (left, p) in [(true, section.a_left), (false, section.a_right)] { + let sign = if left { 1.0 } else { -1.0 }; + let expected_x = 4096.0 * (f64::from(delta * x) - sign * tangent * f64::from(y)) / sq; + let expected_y = 4096.0 * (f64::from(delta * y) + sign * tangent * f64::from(x)) / sq; + assert!( + (f64::from(p.x) - expected_x).abs() <= 0.51, + "x={x},y={y},delta={delta}" + ); + assert!( + (f64::from(p.y) - expected_y).abs() <= 0.51, + "x={x},y={y},delta={delta}" + ); + } + } + } +} diff --git a/iOverlay/tests/int_mesh_tests.rs b/iOverlay/tests/int_mesh_tests.rs new file mode 100644 index 00000000..414d5550 --- /dev/null +++ b/iOverlay/tests/int_mesh_tests.rs @@ -0,0 +1,285 @@ +use i_float::int::{angle::Angle, point::IntPoint}; +use i_overlay::mesh::int::{ + arc::ArcOptions, + outline::offset::IntOutlineOffset, + stroke::offset::IntStrokeOffset, + style::{IntLineCap, IntLineJoin, IntOutlineStyle, IntStrokeStyle}, + variable_stroke::{ + IntStrokeVertex, IntVariableStrokeSource, IntVariableStrokeStyle, offset::IntVariableStrokeOffset, + }, +}; +use i_shape::{flat::buffer::FlatContoursBuffer, int::area::Area}; + +macro_rules! engines { + ($name:ident,$int:ty) => { + #[test] + fn $name() { + let path = [IntPoint::<$int>::new(-4096, 0), IntPoint::new(4096, 0)]; + let mut style = IntStrokeStyle::new(2048); + let butt = path.stroke(&style, false).unwrap(); + assert_eq!(butt.len(), 1); + assert_eq!(butt.area() as i128, 8192 * 2048); + style.start_cap = IntLineCap::Square; + style.end_cap = IntLineCap::Square; + assert_eq!( + path.stroke(&style, false).unwrap().area() as i128, + 10240 * 2048 + ); + style.start_cap = IntLineCap::Round(ArcOptions::default()); + style.end_cap = style.start_cap.clone(); + let round = path.stroke(&style, false).unwrap(); + let capsule_area = round.area() as i128; + assert!((8192 * 2048 + 2800000..8192 * 2048 + 3400000).contains(&capsule_area)); + let variable = path.map(|p| IntStrokeVertex::new(p, 2048)); + let variable_style = IntVariableStrokeStyle::new().arc(ArcOptions::default()); + assert_eq!(variable.variable_stroke(variable_style).unwrap(), round); + let tapered = [ + IntStrokeVertex::new(path[0], 1024), + IntStrokeVertex::new(path[1], 3072), + ]; + assert_eq!(tapered.variable_stroke(variable_style).unwrap().len(), 1); + let corner = [path[0], path[1], IntPoint::new(4096, 4096)]; + for join in [ + IntLineJoin::Bevel, + IntLineJoin::Miter(Angle::from_bits(1 << 28)), + IntLineJoin::Round(ArcOptions::default()), + ] { + style.join = join; + let shapes = corner.stroke(&style, false).unwrap(); + assert_eq!(shapes.len(), 1); + assert!(shapes.area() as i128 > 12000 * 2048); + } + } + }; +} +engines!(mesh_i16, i16); +engines!(mesh_i32, i32); +engines!(mesh_i64, i64); + +#[test] +fn integer_strokes_preserve_precision_above_f64_exact_integers() { + let shift = (1i64 << 57) + 123; + let base = [ + IntPoint::new(-4096i64, 0), + IntPoint::new(4096, 0), + IntPoint::new(2048, 8192), + ]; + let translated = base.map(|p| IntPoint::new(p.x + shift, p.y - shift)); + let undo = |mut shapes: Vec>>>| { + for p in shapes.iter_mut().flatten().flatten() { + p.x -= shift; + p.y += shift; + } + shapes + }; + for join in [ + IntLineJoin::Bevel, + IntLineJoin::Miter(Angle::from_bits(1 << 28)), + IntLineJoin::Round(ArcOptions::default()), + ] { + let style = IntStrokeStyle::new(2048) + .line_join(join) + .start_cap(IntLineCap::Round(ArcOptions::default())) + .end_cap(IntLineCap::Round(ArcOptions::default())); + assert_eq!( + base.stroke(&style, false).unwrap(), + undo(translated.stroke(&style, false).unwrap()) + ); + let outline = IntOutlineStyle::new(1024).line_join(join); + assert_eq!( + base.outline(&outline).unwrap(), + undo(translated.outline(&outline).unwrap()) + ); + } + let variable = base + .into_iter() + .zip([1024, 4096, 2048]) + .map(|(p, w)| IntStrokeVertex::new(p, w)) + .collect::>(); + let shifted = translated + .into_iter() + .zip([1024, 4096, 2048]) + .map(|(p, w)| IntStrokeVertex::new(p, w)) + .collect::>(); + assert_eq!( + variable.variable_stroke(Default::default()).unwrap(), + undo(shifted.variable_stroke(Default::default()).unwrap()) + ); +} + +#[test] +fn flat_stroke_outputs_replace_previous_geometry() { + let path = [IntPoint::new(0, 0), IntPoint::new(8192, 0)]; + let style = IntStrokeStyle::new(2048); + let mut output = FlatContoursBuffer::default(); + let mut expected = FlatContoursBuffer::default(); + expected.set_with_shapes(&path.stroke(&style, false).unwrap()); + path.stroke_into(&style, false, &mut output).unwrap(); + assert_eq!(output, expected); + let empty: Vec = vec![]; + empty.stroke_into(&style, false, &mut output).unwrap(); + assert!(output.points.is_empty() && output.ranges.is_empty()); + let path = path.map(|p| IntStrokeVertex::new(p, 2048)); + expected.set_with_shapes(&path.variable_stroke(Default::default()).unwrap()); + path.variable_stroke_into(Default::default(), &mut output) + .unwrap(); + assert_eq!(output, expected); + let empty: Vec = vec![]; + empty + .variable_stroke_into(Default::default(), &mut output) + .unwrap(); + assert!(output.points.is_empty() && output.ranges.is_empty()); +} + +#[test] +fn validate_expanded_stroke_bounds_without_building() { + let edge = (1i32 << 30) - 512; + let path = [IntPoint::new(edge, 0), IntPoint::new(edge, 8192)]; + assert!(path.validate_stroke(&IntStrokeStyle::new(512)).is_ok()); + assert!(path.validate_stroke(&IntStrokeStyle::new(2048)).is_err()); + assert!( + path.map(|p| IntStrokeVertex::new(p, 2048)) + .validate_variable_stroke() + .is_err() + ); + assert!( + path.map(|p| IntStrokeVertex::new(p, 512)) + .validate_variable_stroke() + .is_ok() + ); +} + +#[test] +fn variable_resources_and_remaining_iterator_count() { + let path = vec![ + IntStrokeVertex::new(IntPoint::new(0, 0), 2048), + IntStrokeVertex::new(IntPoint::new(8192, 0), 4096), + ]; + let expected = path.variable_stroke(Default::default()).unwrap(); + assert_eq!( + path.as_slice().variable_stroke(Default::default()).unwrap(), + expected + ); + let paths = vec![path.clone(), Vec::new()]; + assert_eq!(paths.variable_stroke(Default::default()).unwrap(), expected); + let mut iter = path.iter_variable_paths(); + assert!(iter.next().is_some()); + assert_eq!(iter.count(), 0); + let mut iter = paths.iter_variable_paths(); + assert!(iter.next().is_some()); + assert_eq!(iter.count(), 1); +} + +#[test] +fn i64_large_spans_use_wide_products_for_contacts_and_miters() { + let unit = 1i64 << 56; + let path = [ + IntPoint::new(-16 * unit, 0), + IntPoint::new(0, 0), + IntPoint::new(15 * unit, unit), + ]; + let style = IntStrokeStyle::new(2 * unit).line_join(IntLineJoin::Miter(Angle::from_bits(1 << 28))); + assert_eq!(path.stroke(&style, false).unwrap().len(), 1); + let variable = path + .into_iter() + .zip([unit, 8 * unit, 2 * unit]) + .map(|(p, w)| IntStrokeVertex::new(p, w)) + .collect::>(); + assert_eq!(variable.variable_stroke(Default::default()).unwrap().len(), 1); +} + +#[test] +fn float_fixed_scale_matches_direct_integer_geometry() { + use i_overlay::mesh::float::{ + outline::offset::OutlineOffset, + stroke::offset::StrokeOffset, + style::{LineCap, LineJoin, OutlineStyle, StrokeStyle}, + variable_stroke::{StrokeVertex, VariableStrokeStyle, offset::VariableStrokeOffset}, + }; + let int_path = [ + IntPoint::new(-4096, -4096), + IntPoint::new(4096, -4096), + IntPoint::new(4096, 4096), + IntPoint::new(-4096, 4096), + ]; + let float_path = int_path.map(|p| [p.x as f64, p.y as f64]); + let points = |shapes: Vec>>| { + let mut pts = shapes + .into_iter() + .flatten() + .flatten() + .map(|p| (p.x, p.y)) + .collect::>(); + pts.sort_unstable(); + pts + }; + let float_points = |shapes: Vec>>| { + let mut pts = shapes + .into_iter() + .flatten() + .flatten() + .map(|p| { + assert_eq!(p[0], p[0].round()); + assert_eq!(p[1], p[1].round()); + (p[0] as i32, p[1] as i32) + }) + .collect::>(); + pts.sort_unstable(); + pts + }; + for (int_join, float_join) in [ + (IntLineJoin::Bevel, LineJoin::Bevel), + ( + IntLineJoin::Miter(Angle::from_bits(1 << 29)), + LineJoin::Miter(core::f64::consts::FRAC_PI_4), + ), + ( + IntLineJoin::Round(ArcOptions::default()), + LineJoin::Round(core::f64::consts::FRAC_PI_4), + ), + ] { + let int_style = IntOutlineStyle::new(1024).line_join(int_join); + let float_style = OutlineStyle::new(1024.0).line_join(float_join.clone()); + assert_eq!( + points(int_path.outline(&int_style).unwrap()), + float_points(float_path.outline_fixed_scale(&float_style, 1.0).unwrap()) + ); + let int_style = IntStrokeStyle::new(2048) + .line_join(int_join) + .start_cap(IntLineCap::Round(ArcOptions::default())) + .end_cap(IntLineCap::Square); + let float_style = StrokeStyle::new(2048.0) + .line_join(float_join) + .start_cap(LineCap::Round(core::f64::consts::FRAC_PI_4)) + .end_cap(LineCap::Square); + assert_eq!( + points(int_path.stroke(&int_style, false).unwrap()), + float_points(float_path.stroke_fixed_scale(float_style, false, 1.0).unwrap()) + ); + } + let int_variable = int_path + .into_iter() + .zip([1024, 4096, 2048, 1024]) + .map(|(p, w)| IntStrokeVertex::new(p, w)) + .collect::>(); + let float_variable = float_path + .into_iter() + .zip([1024.0, 4096.0, 2048.0, 1024.0]) + .map(|(p, w)| StrokeVertex::new(p, w)) + .collect::>(); + assert_eq!( + points( + int_variable + .variable_stroke(IntVariableStrokeStyle::new().arc(ArcOptions::default())) + .unwrap() + ), + float_points( + float_variable + .variable_stroke_fixed_scale( + VariableStrokeStyle::new().round_angle(core::f64::consts::FRAC_PI_4), + 1.0 + ) + .unwrap() + ) + ); +} diff --git a/iOverlay/tests/int_outline_bounds_tests.rs b/iOverlay/tests/int_outline_bounds_tests.rs index 41a6521c..6c8dc1f8 100644 --- a/iOverlay/tests/int_outline_bounds_tests.rs +++ b/iOverlay/tests/int_outline_bounds_tests.rs @@ -69,13 +69,16 @@ check_bounds!(expanded_bounds_i32, i32); check_bounds!(expanded_bounds_i64, i64); #[test] -fn empty_input_needs_no_coordinate_space_but_still_checks_join_support() { +fn empty_input_needs_no_coordinate_space_for_any_join() { let empty: Vec = vec![]; assert_eq!(empty.validate_outline(&IntOutlineStyle::new(i32::MIN)), Ok(())); - for join in [IntLineJoin::Miter, IntLineJoin::Round] { + for join in [ + IntLineJoin::Miter(i_float::int::angle::Angle::from_bits(1 << 26)), + IntLineJoin::Round(Default::default()), + ] { assert_eq!( empty.validate_outline(&IntOutlineStyle::new(0).line_join(join)), - Err(IntOutlineError::UnsupportedJoin(join)) + Ok(()) ); } } diff --git a/iOverlay/tests/int_outline_tests.rs b/iOverlay/tests/int_outline_tests.rs index cc816028..6b3e56cb 100644 --- a/iOverlay/tests/int_outline_tests.rs +++ b/iOverlay/tests/int_outline_tests.rs @@ -163,15 +163,18 @@ fn output_direction_is_configurable() { } #[test] -fn errors_preserve_output_and_stubs_are_explicit() { +fn all_joins_build_into_reused_output() { let path = rectangle(0, 0, 8192, 8192); let mut output = FlatContoursBuffer::default(); output.set_with_contour(&path); - let saved = output.clone(); - for join in [IntLineJoin::Miter, IntLineJoin::Round] { + for join in [ + IntLineJoin::Miter(i_float::int::angle::Angle::from_bits(1 << 26)), + IntLineJoin::Round(Default::default()), + ] { let result = path.outline_into(&IntOutlineStyle::new(1024).line_join(join), &mut output); - assert_eq!(result, Err(IntOutlineError::UnsupportedJoin(join))); - assert_eq!(output, saved); + assert_eq!(result, Ok(())); + assert!(!output.points.is_empty()); + assert!(output.points.iter().any(|p| p.x < 0 || p.y < 0)); } } From c1f45f095f79c1256ce9846e62cf30df27bf8c28 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Mon, 14 Sep 2026 21:11:50 +0300 Subject: [PATCH 20/44] impl clean --- iOverlay/src/mesh/float/outline/offset.rs | 3 +- iOverlay/src/mesh/float/stroke/offset.rs | 39 +++++--- iOverlay/src/mesh/float/style.rs | 18 ++-- iOverlay/src/mesh/int/outline/bounds.rs | 49 ++++----- iOverlay/src/mesh/int/outline/build.rs | 4 +- iOverlay/src/mesh/int/outline/offset.rs | 4 +- iOverlay/src/mesh/int/stroke/builder.rs | 12 ++- iOverlay/src/mesh/int/stroke/offset.rs | 115 +++++++++++++++++++--- 8 files changed, 174 insertions(+), 70 deletions(-) diff --git a/iOverlay/src/mesh/float/outline/offset.rs b/iOverlay/src/mesh/float/outline/offset.rs index dd62f463..b4fbbd68 100644 --- a/iOverlay/src/mesh/float/outline/offset.rs +++ b/iOverlay/src/mesh/float/outline/offset.rs @@ -414,7 +414,8 @@ where } let additional_offset = P::Scalar::from_float( - (style.outer_offset.to_f64().abs() + style.inner_offset.to_f64().abs()) * style.join.padding(), + (style.outer_offset.to_f64().abs() + style.inner_offset.to_f64().abs()) + * style.join.padding_factor(), ); let mut rect = FloatRect::with_iter(source.iter_paths().flatten()).unwrap_or(FloatRect::zero()); rect.add_offset(additional_offset); diff --git a/iOverlay/src/mesh/float/stroke/offset.rs b/iOverlay/src/mesh/float/stroke/offset.rs index 32c86213..2c44fb88 100644 --- a/iOverlay/src/mesh/float/stroke/offset.rs +++ b/iOverlay/src/mesh/float/stroke/offset.rs @@ -1,8 +1,10 @@ +use crate::core::fill_rule::FillRule; use crate::core::integer::OverlayInt; +use crate::core::overlay_rule::OverlayRule; use crate::float::overlay::OverlayOptions; use crate::float::scale::FixedScaleOverlayError; use crate::mesh::float::style::StrokeStyle; -use crate::mesh::int::stroke::offset::IntStrokeOffset; +use crate::mesh::int::stroke::offset::build_stroke_overlay_iter; use alloc::vec; use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; @@ -14,7 +16,7 @@ use i_float::int::number::wide_int::WideIntNumber; use i_shape::base::data::Shapes; use i_shape::flat::buffer::FlatContoursBuffer; use i_shape::flat::float::FloatFlatContoursBuffer; -use i_shape::float::adapter::{ResourceToInt, ShapesToFloat}; +use i_shape::float::adapter::ShapesToFloat; use i_shape::float::despike::DeSpikeContour; use i_shape::float::simple::SimplifyContour; use i_shape::source::float::resource::ShapeResource; @@ -492,11 +494,17 @@ where return vec![]; } - let paths = source.to_int_paths(&self.adapter); + let paths = source + .iter_paths() + .map(|path| path.iter().map(|p| self.adapter.float_to_int(p))); let style = self.style.to_int(&self.adapter); - let shapes = paths - .stroke_custom(&style, is_closed_path, options.int_with_adapter(&self.adapter)) - .expect("valid integer stroke"); + let shapes = build_stroke_overlay_iter( + paths, + &style, + is_closed_path, + options.int_with_adapter(&self.adapter), + ) + .overlay(OverlayRule::Subject, FillRule::Positive); let mut float = shapes.to_float(&self.adapter); @@ -525,17 +533,18 @@ where return; } - let paths = source.to_int_paths(&self.adapter); + let paths = source + .iter_paths() + .map(|path| path.iter().map(|p| self.adapter.float_to_int(p))); let style = self.style.to_int(&self.adapter); let mut int_output = FlatContoursBuffer::::with_capacity(0); - paths - .stroke_custom_into( - &style, - is_closed_path, - options.int_with_adapter(&self.adapter), - &mut int_output, - ) - .expect("valid integer stroke"); + build_stroke_overlay_iter( + paths, + &style, + is_closed_path, + options.int_with_adapter(&self.adapter), + ) + .overlay_into(OverlayRule::Subject, FillRule::Positive, &mut int_output); let iter = int_output.points.iter().map(|p| self.adapter.int_to_float(p)); output.set_with_iter(iter, &int_output.ranges); diff --git a/iOverlay/src/mesh/float/style.rs b/iOverlay/src/mesh/float/style.rs index d55fb15b..b077d335 100644 --- a/iOverlay/src/mesh/float/style.rs +++ b/iOverlay/src/mesh/float/style.rs @@ -87,7 +87,7 @@ impl From<&LineJoin> for IntLineJoin { impl LineJoin { /// Conservative multiplier for the join's reach relative to the offset radius. - pub(super) fn padding(&self) -> f64 { + pub(super) fn padding_factor(&self) -> f64 { match IntLineJoin::from(self) { IntLineJoin::Miter(minimum) => { let sin = Angle::from_bits(minimum.bits() / 2).sin() as f64; @@ -193,14 +193,14 @@ impl StrokeStyle

{ } _ => 1.1, }; - P::Scalar::from_float( - 0.5 * self.width.to_f64().max(0.0) - * self - .join - .padding() - .max(cap(&self.start_cap)) - .max(cap(&self.end_cap)), - ) + let r = 0.5 * self.width.to_f64().max(0.0); + + let join_factor = self.join.padding_factor(); + let start_cap_factor = cap(&self.start_cap); + let end_cap_factor = cap(&self.start_cap); + + let factor = join_factor.max(start_cap_factor).max(end_cap_factor); + P::Scalar::from_float(r * factor) } } diff --git a/iOverlay/src/mesh/int/outline/bounds.rs b/iOverlay/src/mesh/int/outline/bounds.rs index 56286d82..3cd129e2 100644 --- a/iOverlay/src/mesh/int/outline/bounds.rs +++ b/iOverlay/src/mesh/int/outline/bounds.rs @@ -5,30 +5,31 @@ use i_float::int::number::int::IntNumber; use i_float::int::rect::IntRect; use i_shape::source::int::resource::IntShapeResource; -pub(super) fn validate + ?Sized>( - source: &S, - style: &IntOutlineStyle, -) -> Result<(), IntOutlineError> { - let padding = - Join::padding(style.join, style.outer_offset).max(Join::padding(style.join, style.inner_offset)); - let Some(rect) = IntRect::with_iter(source.iter_paths().flatten()) else { - return Ok(()); - }; +pub(super) trait OutlineBounds: IntShapeResource { + fn validate_outline_bounds(&self, style: &IntOutlineStyle) -> Result<(), IntOutlineError> { + let padding = + Join::padding(style.join, style.outer_offset).max(Join::padding(style.join, style.inner_offset)); + let Some(rect) = IntRect::with_iter(self.iter_paths().flatten()) else { + return Ok(()); + }; - // Expand in the wide type, including when an offset is I::MIN. Saturating - // at storage limits prevents wrapping during narrowing: these limits are - // themselves outside is_in_safe_range, so an oversized bound stays invalid. - let min = I::MIN.to_wide(); - let max = I::MAX.to_wide(); - let expanded = IntRect::new( - I::from_wide((rect.min_x.to_wide() - padding).max(min)), - I::from_wide((rect.max_x.to_wide() + padding).min(max)), - I::from_wide((rect.min_y.to_wide() - padding).max(min)), - I::from_wide((rect.max_y.to_wide() + padding).min(max)), - ); - if expanded.is_in_safe_range() { - Ok(()) - } else { - Err(IntOutlineError::CoordinateOutOfRange) + // Expand in the wide type, including when an offset is I::MIN. Saturating + // at storage limits prevents wrapping during narrowing: these limits are + // themselves outside is_in_safe_range, so an oversized bound stays invalid. + let min = I::MIN.to_wide(); + let max = I::MAX.to_wide(); + let expanded = IntRect::new( + I::from_wide((rect.min_x.to_wide() - padding).max(min)), + I::from_wide((rect.max_x.to_wide() + padding).min(max)), + I::from_wide((rect.min_y.to_wide() - padding).max(min)), + I::from_wide((rect.max_y.to_wide() + padding).min(max)), + ); + if expanded.is_in_safe_range() { + Ok(()) + } else { + Err(IntOutlineError::CoordinateOutOfRange) + } } } + +impl + ?Sized> OutlineBounds for S {} diff --git a/iOverlay/src/mesh/int/outline/build.rs b/iOverlay/src/mesh/int/outline/build.rs index 86f19b9f..77df2f69 100644 --- a/iOverlay/src/mesh/int/outline/build.rs +++ b/iOverlay/src/mesh/int/outline/build.rs @@ -1,4 +1,4 @@ -use super::bounds; +use super::bounds::OutlineBounds; use super::builder::OutlineBuilder; use super::builder_join::JoinBuilder; use super::offset::IntOutlineError; @@ -23,7 +23,7 @@ pub(super) trait BuildOutlineOverlay: IntShapeResource { options: IntOverlayOptions, ) -> Result, IntOutlineError> { debug_assert!( - bounds::validate(self, style).is_ok(), + self.validate_outline_bounds(style).is_ok(), "outline bounds exceed the safe coordinate range" ); Ok(self.build_overlay_with_builders( diff --git a/iOverlay/src/mesh/int/outline/offset.rs b/iOverlay/src/mesh/int/outline/offset.rs index 7659549d..a639702b 100644 --- a/iOverlay/src/mesh/int/outline/offset.rs +++ b/iOverlay/src/mesh/int/outline/offset.rs @@ -1,5 +1,5 @@ //! Integer outline construction used directly and by the float adapter. -use super::bounds; +use super::bounds::OutlineBounds; use super::build::BuildOutlineOverlay; use crate::core::fill_rule::FillRule; use crate::core::integer::OverlayInt; @@ -68,7 +68,7 @@ pub trait IntOutlineOffset: IntShapeResource { /// would fit. Empty input passes. This does not validate /// winding, topology, or the accumulated area of repeated winding. fn validate_outline(&self, style: &IntOutlineStyle) -> Result<(), IntOutlineError> { - bounds::validate(self, style) + self.validate_outline_bounds(style) } fn outline(&self, style: &IntOutlineStyle) -> Result, IntOutlineError> { diff --git a/iOverlay/src/mesh/int/stroke/builder.rs b/iOverlay/src/mesh/int/stroke/builder.rs index 3ca828a2..b60d6434 100644 --- a/iOverlay/src/mesh/int/stroke/builder.rs +++ b/iOverlay/src/mesh/int/stroke/builder.rs @@ -154,19 +154,23 @@ impl StrokeBuilder { } pub(super) fn build( &mut self, - path: &[IntPoint], + path: impl IntoIterator>, closed: bool, segments: &mut Vec>, ) { self.sections.clear(); if self.radius <= I::ONE { + // Still consume points for the caller's debug range validation. + #[cfg(debug_assertions)] + for _ in path {} return; } - let Some(&first) = path.first() else { + let mut path = path.into_iter(); + let Some(first) = path.next() else { return; }; let mut previous = first; - for &next in &path[1..] { + for next in path { if previous != next { self.sections.push(Section::new(previous, next, self.radius)); previous = next; @@ -276,7 +280,7 @@ mod tests { end, ]; let mut segments = Vec::new(); - builder.build(&path, closed, &mut segments); + builder.build(path, closed, &mut segments); assert!(segments.iter().all(|s| s.x_segment.a < s.x_segment.b)); if width >= 4 { assert!(!segments.is_empty()); diff --git a/iOverlay/src/mesh/int/stroke/offset.rs b/iOverlay/src/mesh/int/stroke/offset.rs index 07be5cc9..682ca0ec 100644 --- a/iOverlay/src/mesh/int/stroke/offset.rs +++ b/iOverlay/src/mesh/int/stroke/offset.rs @@ -8,7 +8,7 @@ use crate::core::{ use crate::mesh::int::outline::offset::IntOutlineError; use crate::mesh::int::style::IntStrokeStyle; use alloc::vec::Vec; -use i_float::int::rect::IntRect; +use i_float::int::{point::IntPoint, rect::IntRect}; use i_shape::{ flat::buffer::FlatContoursBuffer, int::shape::IntShapes, source::int::resource::IntShapeResource, }; @@ -82,18 +82,107 @@ trait BuildStrokeOverlay: IntStrokeOffset { closed: bool, options: IntOverlayOptions, ) -> Overlay { - debug_assert!( - self.validate_stroke(style).is_ok(), - "stroke bounds exceed the safe coordinate range" - ); - let mut builder = StrokeBuilder::new(style); - let mut segments = Vec::new(); - for path in self.iter_paths() { - builder.build(path, closed, &mut segments); - } - let mut overlay = Overlay::with_segments(segments); - overlay.options = options; - overlay + build_stroke_overlay_iter( + self.iter_paths().map(|path| path.iter().copied()), + style, + closed, + options, + ) } } impl + ?Sized> BuildStrokeOverlay for S {} + +/// Builds one overlay from paths consumed once, without retaining input points. +pub(crate) fn build_stroke_overlay_iter( + paths: Paths, + style: &IntStrokeStyle, + closed: bool, + options: IntOverlayOptions, +) -> Overlay +where + I: OverlayInt, + Paths: IntoIterator, + Path: IntoIterator>, +{ + let mut builder = StrokeBuilder::new(style); + let mut segments = Vec::new(); + #[cfg(debug_assertions)] + let padding = StrokeBuilder::padding(style); + for path in paths { + #[cfg(debug_assertions)] + let path = path.into_iter().inspect(|point| { + // Checking each expanded point is equivalent to checking the expanded + // input rectangle, and happens before the point enters geometry math. + debug_assert!( + crate::mesh::int::bounds::expanded_is_safe(IntRect::with_point(*point), padding), + "stroke bounds exceed the safe coordinate range" + ); + }); + builder.build(path, closed, &mut segments); + } + let mut overlay = Overlay::with_segments(segments); + overlay.options = options; + overlay +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + use core::cell::Cell; + + #[test] + fn one_pass_paths_match_resource_strokes() { + let paths = vec![ + vec![], + vec![IntPoint::new(0, 0)], + vec![ + IntPoint::new(0, 0), + IntPoint::new(0, 0), + IntPoint::new(8192, 0), + IntPoint::new(8192, 8192), + ], + vec![IntPoint::new(4096, -4096), IntPoint::new(4096, 4096)], + vec![], + ]; + let style = IntStrokeStyle::new(1024); + for closed in [false, true] { + let expected = paths.stroke(&style, closed).unwrap(); + let visited = Cell::new(0); + let visited_ref = &visited; + let mut expected_visited = 0; + let iter = paths.clone().into_iter().map(move |path| { + // Finish each path before requesting the next one. + assert_eq!(visited_ref.get(), expected_visited); + expected_visited += path.len(); + let mut points = path.into_iter(); + // A single-pass point iterator; no Clone or slice access required. + core::iter::from_fn(move || { + let point = points.next()?; + visited_ref.set(visited_ref.get() + 1); + Some(point) + }) + }); + let actual = build_stroke_overlay_iter(iter, &style, closed, Default::default()) + .overlay(OverlayRule::Subject, FillRule::Positive); + assert_eq!(actual, expected); + assert_eq!(visited.get(), paths.iter().map(Vec::len).sum::()); + } + } + + #[cfg(debug_assertions)] + #[test] + #[should_panic(expected = "stroke bounds exceed the safe coordinate range")] + fn iterator_checks_range_before_building_section() { + let paths = [[IntPoint::new(0, 0), IntPoint::new(i32::MAX, 0)]]; + build_stroke_overlay_iter(paths, &IntStrokeStyle::new(1024), false, Default::default()); + } + + #[cfg(debug_assertions)] + #[test] + #[should_panic(expected = "stroke bounds exceed the safe coordinate range")] + fn zero_radius_still_checks_range() { + let paths = [[IntPoint::new(i32::MAX, 0)]]; + build_stroke_overlay_iter(paths, &IntStrokeStyle::new(0), false, Default::default()); + } +} From 3622caf7007d1ddb1d48a20589f4096954623a7c Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Tue, 15 Sep 2026 19:37:24 +0300 Subject: [PATCH 21/44] float math --- iOverlay/Cargo.toml | 1 + iOverlay/README.md | 6 + iOverlay/docs/stroke_math.md | 57 ++++ iOverlay/src/mesh/float/style.rs | 13 +- iOverlay/src/mesh/int/join.rs | 20 +- iOverlay/src/mesh/int/math.rs | 4 + iOverlay/src/mesh/int/math/backend.rs | 21 ++ iOverlay/src/mesh/int/math/float.rs | 284 +++++++++++++++++++ iOverlay/src/mesh/int/math/integer.rs | 59 ++++ iOverlay/src/mesh/int/outline/bounds.rs | 4 +- iOverlay/src/mesh/int/outline/build.rs | 4 +- iOverlay/src/mesh/int/stroke/bench.rs | 113 ++++++++ iOverlay/src/mesh/int/stroke/builder.rs | 71 ++--- iOverlay/src/mesh/int/stroke/mod.rs | 3 + iOverlay/src/mesh/int/stroke/offset.rs | 83 ++++-- iOverlay/src/mesh/int/style.rs | 10 + iOverlay/src/mesh/math.rs | 14 + iOverlay/src/mesh/mod.rs | 1 + iOverlay/tests/stroke_cap_precision_tests.rs | 81 +++--- iOverlay/tests/stroke_coverage_tests.rs | 76 ++--- iOverlay/tests/stroke_direction_tests.rs | 21 +- iOverlay/tests/stroke_math_tests.rs | 133 +++++++++ 22 files changed, 932 insertions(+), 147 deletions(-) create mode 100644 iOverlay/docs/stroke_math.md create mode 100644 iOverlay/src/mesh/int/math/backend.rs create mode 100644 iOverlay/src/mesh/int/math/float.rs create mode 100644 iOverlay/src/mesh/int/math/integer.rs create mode 100644 iOverlay/src/mesh/int/stroke/bench.rs create mode 100644 iOverlay/src/mesh/math.rs create mode 100644 iOverlay/tests/stroke_math_tests.rs diff --git a/iOverlay/Cargo.toml b/iOverlay/Cargo.toml index 2d45c275..f0560a66 100644 --- a/iOverlay/Cargo.toml +++ b/iOverlay/Cargo.toml @@ -12,6 +12,7 @@ readme = "README.md" categories = ["algorithms", "graphics", "science::geo", "mathematics", "no-std"] [dependencies] +libm = "0.2" i_tree = { version = "^0.19.0" } i_key_sort = { version = "^0.11.0" } diff --git a/iOverlay/README.md b/iOverlay/README.md index bc4b8e02..13ba2e34 100644 --- a/iOverlay/README.md +++ b/iOverlay/README.md @@ -511,6 +511,12 @@ Use `validate_outline(&style)`, `validate_stroke(&style)`, or Construction asserts bounds in debug builds and trusts the caller in release. All three APIs provide `*_into` methods that replace a reusable flat output buffer. +Constant-width stroke also offers experimental float construction math through +`StrokeStyle::math(MathMode::Float)` or `IntStrokeStyle::math(MathMode::Float)`, +with `MathMode` in `mesh::math`. Directions are stored as `UnitIntVector`; integer +coordinates and boolean operations are retained. `Integer` remains the default. +See [stroke math modes and preliminary timings](docs/stroke_math.md) for details. + ### Offsetting a Path Path Example diff --git a/iOverlay/docs/stroke_math.md b/iOverlay/docs/stroke_math.md new file mode 100644 index 00000000..e1a9a1f5 --- /dev/null +++ b/iOverlay/docs/stroke_math.md @@ -0,0 +1,57 @@ +# Stroke math prototype + +Both `StrokeStyle` and `IntStrokeStyle` accept `.math(MathMode::Float)` from `i_overlay::mesh::math`. `Integer` remains the default. This prototype selects math for constant-width stroke only; outline and variable-width stroke retain their current integer arithmetic. + +```rust +use i_overlay::mesh::float::{stroke::offset::StrokeOffset, style::StrokeStyle}; +use i_overlay::mesh::math::MathMode; + +let path = [[0.0, 0.0], [3.0, 4.0]]; +let style = StrokeStyle::new(1.0).math(MathMode::Float); +let result = path.stroke(style, false); +``` + +Construction dispatches once to a generic `StrokeBuilder`. Float math computes normalization and trigonometry in f64, then stores directions in `UnitIntVector`. It uses the new checked `UnitIntVector::try_from_float` constructor from the local iFloat dependency. Scaling, custom-cap rotation, miter intersections, integer coordinates, and the boolean engine stay integer. Differences and cross/dot products are formed before float conversion to preserve local geometry at large i64 origins. + +Float arcs cache a rotation and reuse their output allocation. Floating rotation state is retained between samples; quantization of an emitted direction is not fed back into the next rotation. The step reserves angular error for f64 arithmetic and fixed-scale component truncation. The original integer endpoints are retained. `ArcOptions::max_step` still applies, but `rotation_precision` is specific to the Integer mode and ignored by Float. + +The checked constructor truncates components toward zero and verifies the integer squared norm. It does not normalize inputs. Float normalization/rotation is contracted slightly before conversion so rounding cannot create a direction longer than one. + +The modes may produce different rounded vertices and arc tessellations. Float mode does not promise cross-platform bitwise reproducibility. Defaults are unchanged; code using exhaustive style struct literals must now supply the new `math` field. + +## Preliminary timing + +Measured on macOS arm64 with rustc 1.98.1, optimized release build. Each operation uses 16 paths of 96 points. Smooth paths follow a sampled sine; jagged paths alternate height. Stroke width is 512 integer units, with a round start cap and square end cap. The reported value is the median of seven batches of ten calls, after one warmup call. Construction includes allocation; full stroke includes boolean extraction. + +These are synthetic cases on one machine, not a guarantee of application speedup. Output tessellations differ slightly: Float emitted 16 fewer segments in each case (one per path), so end-to-end numbers do not isolate arithmetic cost. + +| Engine | Paths | Join | Build Integer / Float, µs | Full Integer / Float, µs | Full time reduction | +|---|---|---|---:|---:|---:| +| i32 | smooth | Bevel | 129.96 / 33.80 | 2610.26 / 2588.36 | 0.8% | +| i32 | smooth | Miter | 85.73 / 53.88 | 3522.31 / 3447.71 | 2.1% | +| i32 | smooth | Round | 69.13 / 48.25 | 2640.53 / 2637.67 | 0.1% | +| i32 | jagged | Bevel | 44.28 / 34.40 | 1150.73 / 1143.12 | 0.7% | +| i32 | jagged | Miter | 327.85 / 310.98 | 1794.52 / 1754.57 | 2.2% | +| i32 | jagged | Round | 116.77 / 81.83 | 1706.99 / 1664.77 | 2.5% | +| i64 | smooth | Bevel | 135.62 / 49.92 | 3122.18 / 3014.88 | 3.4% | +| i64 | smooth | Miter | 193.69 / 100.92 | 4106.54 / 3946.51 | 3.9% | +| i64 | smooth | Round | 227.73 / 81.46 | 3189.83 / 3040.92 | 4.7% | +| i64 | jagged | Bevel | 138.93 / 47.17 | 1388.85 / 1293.50 | 6.9% | +| i64 | jagged | Miter | 929.12 / 812.11 | 2581.74 / 2478.71 | 4.0% | +| i64 | jagged | Round | 285.85 / 136.63 | 2112.45 / 1932.25 | 8.5% | + +Normalization alone was about 1.8–4.8× faster in these cases. Segment construction improved more than total stroke time; boolean work dominates the full operation. Keep Float opt-in while collecting representative application benchmarks. + +Reproduce: + +```sh +cargo test --release --lib benchmark_math_modes -- --ignored --nocapture +``` + +## Validation + +- Existing integer mesh/arc/outline regression tests retain the default behavior. +- Both math modes cover constant-width round-stroke vertex disks, path reversal, coarse caps, empty paths, duplicates, and reused flat output. +- Float arc tests check ordered samples, maximum angular gaps, exact integer norm bounds, and allocation reuse for i16/i32/i64. +- Large-origin i64 tests translate the input by 2^60 and compare the translated-back stroke. +- iFloat constructor tests include non-finite/out-of-range input and a norm violation too small to detect by squaring in f64. diff --git a/iOverlay/src/mesh/float/style.rs b/iOverlay/src/mesh/float/style.rs index b077d335..0350d230 100644 --- a/iOverlay/src/mesh/float/style.rs +++ b/iOverlay/src/mesh/float/style.rs @@ -1,5 +1,6 @@ use crate::mesh::int::arc::ArcOptions; use crate::mesh::int::style::{IntLineCap, IntLineJoin, IntStrokeStyle}; +use crate::mesh::math::MathMode; use alloc::rc::Rc; use alloc::vec::Vec; use core::f64::consts::PI; @@ -50,6 +51,8 @@ pub struct StrokeStyle { pub end_cap: LineCap

, /// The join style where two lines meet. pub join: LineJoin, + /// Arithmetic for stroke construction. Integer remains the default. + pub math: MathMode, } /// Defines the outline style for offsetting shapes. @@ -145,6 +148,12 @@ impl StrokeStyle

{ self } + /// Selects construction arithmetic; the final boolean operation stays integer. + pub fn math(mut self, math: MathMode) -> Self { + self.math = math; + self + } + pub(super) fn to_int(&self, adapter: &FloatPointAdapter) -> IntStrokeStyle { let radius = P::Scalar::from_float(0.5 * self.width.to_f64().max(0.0)); let cap = |cap: &LineCap

| match cap.clone().normalize() { @@ -173,6 +182,7 @@ impl StrokeStyle

{ start_cap: cap(&self.start_cap), end_cap: cap(&self.end_cap), join: IntLineJoin::from(&self.join), + math: self.math, } } @@ -197,7 +207,7 @@ impl StrokeStyle

{ let join_factor = self.join.padding_factor(); let start_cap_factor = cap(&self.start_cap); - let end_cap_factor = cap(&self.start_cap); + let end_cap_factor = cap(&self.end_cap); let factor = join_factor.max(start_cap_factor).max(end_cap_factor); P::Scalar::from_float(r * factor) @@ -211,6 +221,7 @@ impl Default for StrokeStyle

{ start_cap: LineCap::Butt, end_cap: LineCap::Butt, join: LineJoin::Bevel, + math: MathMode::Integer, } } } diff --git a/iOverlay/src/mesh/int/join.rs b/iOverlay/src/mesh/int/join.rs index f638a645..5968084f 100644 --- a/iOverlay/src/mesh/int/join.rs +++ b/iOverlay/src/mesh/int/join.rs @@ -1,4 +1,6 @@ -use super::arc::{ArcBuilder, ArcDirection}; +use super::arc::ArcDirection; +use super::math::backend::{ArcMath, MeshMath}; +use super::math::integer::IntegerMath; use super::math::{abs, mul_div, point, scaled_point, vector}; use super::style::IntLineJoin; use crate::mesh::subject::SubjectSegments; @@ -8,22 +10,22 @@ use i_float::int::angle::Angle; use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; use i_float::int::{point::IntPoint, unit_vector::UnitIntVector}; -pub(super) enum Join { +pub(super) enum Join = IntegerMath> { Bevel, Miter { minimum: u32, sin: i32, cos: i32 }, - Round(ArcBuilder), + Round(M::Arc), } -impl Join { +impl> Join { pub(super) fn new(style: IntLineJoin) -> Self { match style { IntLineJoin::Bevel => Self::Bevel, - IntLineJoin::Round(options) => Self::Round(ArcBuilder::new(options)), + IntLineJoin::Round(options) => Self::Round(M::Arc::new(options)), IntLineJoin::Miter(angle) => { let minimum = angle .bits() .clamp((1u32 << 31) / 100, ((1u64 << 31) * 99 / 100) as u32); - let (sin, cos) = Angle::from_bits(minimum / 2).sin_cos(); + let (sin, cos) = M::sin_cos(Angle::from_bits(minimum / 2)); Self::Miter { minimum, sin, cos } } } @@ -63,11 +65,11 @@ impl Join { match self { Self::Bevel => segments.push_non_degenerate(a, b), Self::Round(arc) => { - let Some(from) = crate::mesh::int::math::direction(a - center) else { + let Some(from) = M::normalize(a - center) else { segments.push_non_degenerate(a, b); return; }; - let Some(to) = crate::mesh::int::math::direction(b - center) else { + let Some(to) = M::normalize(b - center) else { segments.push_non_degenerate(a, b); return; }; @@ -87,7 +89,7 @@ impl Join { segments.push_non_degenerate(a, b); return; } - let turn = Angle::between(incoming, outgoing).bits(); + let turn = M::angle_between(incoming, outgoing).bits(); let turn = turn.min(turn.wrapping_neg()); if (1u32 << 31) - turn < *minimum { let extension = mul_div::( diff --git a/iOverlay/src/mesh/int/math.rs b/iOverlay/src/mesh/int/math.rs index a09103e2..a8996d6a 100644 --- a/iOverlay/src/mesh/int/math.rs +++ b/iOverlay/src/mesh/int/math.rs @@ -1,3 +1,7 @@ +pub(crate) mod backend; +pub(crate) mod float; +pub(crate) mod integer; + use i_float::int::number::{ int::IntNumber, product_uint::UIntProduct, uint::UIntNumber, wide_int::WideIntNumber, }; diff --git a/iOverlay/src/mesh/int/math/backend.rs b/iOverlay/src/mesh/int/math/backend.rs new file mode 100644 index 00000000..b9ba8db7 --- /dev/null +++ b/iOverlay/src/mesh/int/math/backend.rs @@ -0,0 +1,21 @@ +use crate::mesh::int::arc::{ArcDirection, ArcOptions}; +use i_float::int::{ + angle::Angle, number::int::IntNumber, point::IntPoint, unit_vector::UnitIntVector, vector::IntVector, +}; + +/// Construction arithmetic only; coordinates and topology remain integer. +pub(crate) trait MeshMath: Copy { + type Arc: ArcMath>; + fn sin_cos(angle: Angle) -> (i32, i32); + fn angle_between(from: UnitIntVector, to: UnitIntVector) -> Angle; + + fn normalize(vector: IntVector) -> Option>; + fn scale(direction: UnitIntVector, distance: I) -> IntVector; + fn rotate(direction: UnitIntVector, local: IntPoint) -> IntVector; + fn guard_padding(padding: I::Wide) -> I::Wide; +} + +pub(crate) trait ArcMath { + fn new(options: ArcOptions) -> Self; + fn build(&mut self, from: D, to: D, direction: ArcDirection) -> &[D]; +} diff --git a/iOverlay/src/mesh/int/math/float.rs b/iOverlay/src/mesh/int/math/float.rs new file mode 100644 index 00000000..f4a28c3b --- /dev/null +++ b/iOverlay/src/mesh/int/math/float.rs @@ -0,0 +1,284 @@ +use super::backend::{ArcMath, MeshMath}; +use super::integer::IntegerMath; + +use crate::mesh::int::arc::{ArcDirection, ArcOptions}; +use alloc::vec::Vec; +use core::f64::consts::TAU; +use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; +use i_float::int::{angle::Angle, point::IntPoint, unit_vector::UnitIntVector, vector::IntVector}; + +#[derive(Clone, Copy)] +pub(crate) struct FloatMath; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct FloatUnitVector { + pub(super) x: f64, + pub(super) y: f64, +} + +impl FloatUnitVector { + #[cfg(test)] + fn cross(self, other: Self) -> f64 { + self.x * other.y - self.y * other.x + } + #[cfg(test)] + fn dot(self, other: Self) -> f64 { + self.x * other.x + self.y * other.y + } +} + +impl MeshMath for FloatMath { + type Arc = FloatArc; + fn sin_cos(angle: Angle) -> (i32, i32) { + let (sin, cos) = libm::sincos(angle.bits() as f64 * (TAU / 4294967296.0)); + ( + libm::round(sin * (1_u32 << 30) as f64) as i32, + libm::round(cos * (1_u32 << 30) as f64) as i32, + ) + } + + fn angle_between(from: UnitIntVector, to: UnitIntVector) -> Angle { + // Form cross/dot exactly before conversion, preserving the side of a + // near-parallel turn even when f64 products would cancel to zero. + let a = super::vector(from); + let b = super::vector(to); + let cross = a.cross_product(b); + let dot = a.dot_product(b); + let angle = Angle::from_radians(libm::atan2(cross.to_f64(), dot.to_f64())); + if cross != I::Wide::ZERO && angle.bits() == 0 { + Angle::from_bits(if cross > I::Wide::ZERO { 1 } else { u32::MAX }) + } else { + angle + } + } + + #[inline] + fn normalize(v: IntVector) -> Option> { + if v.x == I::Wide::ZERO && v.y == I::Wide::ZERO { + return None; + } + // The caller subtracts integer origins before conversion. Squaring even + // an i128 component fits f64, so no rescaling is needed here. + let x = v.x.to_f64(); + let y = v.y.to_f64(); + if x == 0.0 { + return UnitIntVector::try_from_float(0.0, y.signum()); + } + if y == 0.0 { + return UnitIntVector::try_from_float(x.signum(), 0.0); + } + let length = libm::sqrt(x * x + y * y); + let inward = 1.0 - 4.0 * f64::EPSILON; + UnitIntVector::try_from_float(x / length * inward, y / length * inward) + } + + #[inline] + fn scale(direction: UnitIntVector, distance: I) -> IntVector { + direction.scale(distance) + } + + #[inline] + fn rotate(direction: UnitIntVector, local: IntPoint) -> IntVector { + >::rotate(direction, local) + } + + fn guard_padding(padding: I::Wide) -> I::Wide { + // Reserve 2^-34 relative error for normalization/rotation drift, plus + // four grid units for final coordinate rounding. i16 only needs the + // absolute reserve and its wide type cannot be shifted by 34. + let relative = if I::BITS > 16 { + padding >> 34 + } else { + I::Wide::ZERO + }; + padding + relative + I::Wide::FOUR + } +} + +/// Cached f64 rotations with reusable storage. Endpoints remain the original +/// integer contact points in the caller, never reconstructed from these rays. +pub(crate) struct FloatArc { + step: f64, + angle_error: f64, + sin: f64, + cos: f64, + directions: Vec>, +} + +impl FloatArc { + const MAX_ROTATIONS: usize = 1536; + // A generous angular budget for atan2 and at most 1536 matrix applications. + // The smaller cached step leaves room for this error in the final gap. + const ANGLE_ERROR: f64 = 64.0 * Self::MAX_ROTATIONS as f64 * f64::EPSILON; +} + +impl ArcMath> for FloatArc { + fn new(options: ArcOptions) -> Self { + let bits = options + .max_step + .bits() + .clamp(ArcOptions::MIN_STEP.bits(), ArcOptions::MAX_STEP.bits()); + let max_step = bits as f64 * (TAU / 4294967296.0); + // Component truncation adds at most about sqrt(2)/S angular error + // per output ray; reserve 2/S for each end of a gap. + let angle_error = Self::ANGLE_ERROR + 2.0 / UnitIntVector::::DENOMINATOR.to_f64(); + let step = max_step - 2.0 * angle_error; + let (sin, cos) = libm::sincos(step); + Self { + step, + angle_error, + sin, + cos, + directions: Vec::new(), + } + } + + fn build( + &mut self, + from: UnitIntVector, + to: UnitIntVector, + direction: ArcDirection, + ) -> &[UnitIntVector] { + self.directions.clear(); + let a = super::vector(from); + let b = super::vector(to); + let cross = a.cross_product(b).to_f64(); + let dot = a.dot_product(b).to_f64(); + let scale = UnitIntVector::::DENOMINATOR.to_f64(); + let from = float_vector(from); + let sign = match direction { + ArcDirection::Clockwise => -1.0, + ArcDirection::Counterclockwise => 1.0, + }; + let cross = sign * cross; + // Conservatively skip short arcs without atan2. Account for unit-vector + // normalization error when comparing the dot product with cos(step). + if cross >= 0.0 && dot / (scale * scale) >= self.cos + 8.0 * f64::EPSILON { + return &self.directions; + } + let mut sweep = libm::atan2(cross, dot); + if sweep < 0.0 { + sweep += TAU; + } + let count = libm::floor((sweep - self.angle_error).max(0.0) / self.step) as usize; + debug_assert!(count <= Self::MAX_ROTATIONS); + self.directions.reserve(count); + let sin = sign * self.sin; + let mut current = from; + for _ in 0..count { + current = FloatUnitVector { + x: self.cos * current.x - sin * current.y, + y: sin * current.x + self.cos * current.y, + }; + // Keep f64 rotation state between steps; quantization does not + // accumulate. Contract slightly to cover rotation roundoff before + // the constructor's exact integer length check. + let inward = 1.0 - Self::ANGLE_ERROR; + self.directions.push( + UnitIntVector::try_from_float(current.x * inward, current.y * inward) + .expect("contracted rotation stays inside the unit disk"), + ); + } + &self.directions + } +} + +fn float_vector(direction: UnitIntVector) -> FloatUnitVector { + let scale = UnitIntVector::::DENOMINATOR.to_f64(); + FloatUnitVector { + x: direction.x().to_wide().to_f64() / scale, + y: direction.y().to_wide().to_f64() / scale, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn check_arcs() { + let scale = UnitIntVector::::DENOMINATOR; + let unit = |angle: f64| { + let (sin, cos) = libm::sincos(angle); + UnitIntVector::::try_from_float( + cos * (1.0 - 8.0 * f64::EPSILON), + sin * (1.0 - 8.0 * f64::EPSILON), + ) + .unwrap() + }; + let sweep = |a: UnitIntVector, b: UnitIntVector, sign: f64| { + let a = float_vector(a); + let b = float_vector(b); + let angle = libm::atan2(sign * a.cross(b), a.dot(b)); + if angle < 0.0 { angle + TAU } else { angle } + }; + for bits in [ArcOptions::MIN_STEP.bits(), 1 << 26, ArcOptions::MAX_STEP.bits()] { + let max_step = bits as f64 * TAU / 4294967296.0; + let mut arc = FloatArc::::new(ArcOptions { + max_step: Angle::from_bits(bits), + rotation_precision: 5, + }); + for start in [0.0, 0.3, 1.7, 3.1, 4.9] { + for extent in [ + 0.0, + 1e-8, + max_step, + max_step + 1e-6, + 1.0, + core::f64::consts::PI, + TAU - 1e-8, + ] { + for direction in [ArcDirection::Clockwise, ArcDirection::Counterclockwise] { + let sign = if direction == ArcDirection::Clockwise { + -1.0 + } else { + 1.0 + }; + let from = unit(start); + let to = unit(start + sign * extent); + let total = sweep(from, to, sign); + let mut previous = 0.0; + for &point in arc.build(from, to, direction) { + let x = point.x().to_wide(); + let y = point.y().to_wide(); + assert!(x * x + y * y <= scale * scale); + let progress = sweep(from, point, sign); + assert!( + progress > previous && progress < total, + "bits={}, progress={progress}, previous={previous}, total={total}", + I::BITS + ); + assert!(progress - previous <= max_step + 1e-12); + previous = progress; + } + assert!(total - previous <= max_step + 1e-12); + } + } + } + arc.build(unit(0.0), unit(3.0), ArcDirection::Clockwise); + let capacity = arc.directions.capacity(); + let ptr = arc.directions.as_ptr(); + for _ in 0..3 { + assert!( + arc.build(unit(0.0), unit(0.0), ArcDirection::Clockwise) + .is_empty() + ); + arc.build(unit(0.0), unit(3.0), ArcDirection::Clockwise); + assert_eq!(arc.directions.capacity(), capacity); + assert_eq!(arc.directions.as_ptr(), ptr); + } + } + } + + #[test] + fn arc_bounds_and_reuse_i16() { + check_arcs::(); + } + #[test] + fn arc_bounds_and_reuse_i32() { + check_arcs::(); + } + #[test] + fn arc_bounds_and_reuse_i64() { + check_arcs::(); + } +} diff --git a/iOverlay/src/mesh/int/math/integer.rs b/iOverlay/src/mesh/int/math/integer.rs new file mode 100644 index 00000000..72153bbe --- /dev/null +++ b/iOverlay/src/mesh/int/math/integer.rs @@ -0,0 +1,59 @@ +use super::backend::{ArcMath, MeshMath}; +use super::{direction, vector}; +use crate::mesh::int::arc::{ArcBuilder, ArcDirection, ArcOptions}; +use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; +use i_float::int::{angle::Angle, point::IntPoint, unit_vector::UnitIntVector, vector::IntVector}; + +#[derive(Clone, Copy)] +pub(crate) struct IntegerMath; + +impl MeshMath for IntegerMath { + type Arc = ArcBuilder; + #[inline] + fn sin_cos(angle: Angle) -> (i32, i32) { + angle.sin_cos() + } + #[inline] + fn angle_between(from: UnitIntVector, to: UnitIntVector) -> Angle { + Angle::between(from, to) + } + + #[inline] + fn normalize(v: IntVector) -> Option> { + direction(v) + } + + #[inline] + fn scale(direction: UnitIntVector, distance: I) -> IntVector { + direction.scale(distance) + } + + #[inline] + fn rotate(direction: UnitIntVector, local: IntPoint) -> IntVector { + let v = vector(direction); + let shift = UnitIntVector::::DENOMINATOR.ilog2(); + IntVector::new( + (v.x * local.x.to_wide() - v.y * local.y.to_wide()).shr_round(shift), + (v.y * local.x.to_wide() + v.x * local.y.to_wide()).shr_round(shift), + ) + } + + #[inline] + fn guard_padding(padding: I::Wide) -> I::Wide { + padding + } +} + +impl ArcMath> for ArcBuilder { + fn new(options: ArcOptions) -> Self { + ArcBuilder::new(options) + } + fn build( + &mut self, + from: UnitIntVector, + to: UnitIntVector, + direction: ArcDirection, + ) -> &[UnitIntVector] { + ArcBuilder::build(self, from, to, direction) + } +} diff --git a/iOverlay/src/mesh/int/outline/bounds.rs b/iOverlay/src/mesh/int/outline/bounds.rs index 3cd129e2..177388b2 100644 --- a/iOverlay/src/mesh/int/outline/bounds.rs +++ b/iOverlay/src/mesh/int/outline/bounds.rs @@ -7,8 +7,8 @@ use i_shape::source::int::resource::IntShapeResource; pub(super) trait OutlineBounds: IntShapeResource { fn validate_outline_bounds(&self, style: &IntOutlineStyle) -> Result<(), IntOutlineError> { - let padding = - Join::padding(style.join, style.outer_offset).max(Join::padding(style.join, style.inner_offset)); + let padding = Join::::padding(style.join, style.outer_offset) + .max(Join::::padding(style.join, style.inner_offset)); let Some(rect) = IntRect::with_iter(self.iter_paths().flatten()) else { return Ok(()); }; diff --git a/iOverlay/src/mesh/int/outline/build.rs b/iOverlay/src/mesh/int/outline/build.rs index 77df2f69..3e46d4ef 100644 --- a/iOverlay/src/mesh/int/outline/build.rs +++ b/iOverlay/src/mesh/int/outline/build.rs @@ -28,8 +28,8 @@ pub(super) trait BuildOutlineOverlay: IntShapeResource { ); Ok(self.build_overlay_with_builders( options, - OutlineBuilder::new(style.outer_offset, Join::new(style.join)), - OutlineBuilder::new(style.inner_offset, Join::new(style.join)), + OutlineBuilder::new(style.outer_offset, Join::::new(style.join)), + OutlineBuilder::new(style.inner_offset, Join::::new(style.join)), )) } diff --git a/iOverlay/src/mesh/int/stroke/bench.rs b/iOverlay/src/mesh/int/stroke/bench.rs new file mode 100644 index 00000000..169f865c --- /dev/null +++ b/iOverlay/src/mesh/int/stroke/bench.rs @@ -0,0 +1,113 @@ +//! Manual comparison; run with release optimizations and an otherwise idle machine. +extern crate std; +use super::offset::build_stroke_overlay_iter; +use crate::core::{fill_rule::FillRule, integer::OverlayInt, overlay_rule::OverlayRule}; +use crate::mesh::int::{ + arc::ArcOptions, + math::{backend::MeshMath, float::FloatMath, integer::IntegerMath}, + style::{IntLineCap, IntLineJoin, IntStrokeStyle}, +}; +use crate::mesh::math::MathMode; +use alloc::vec::Vec; +use core::hint::black_box; +use i_float::int::{angle::Angle, point::IntPoint}; +use std::{println, time::Instant}; + +fn measure(mut run: impl FnMut() -> usize) -> (f64, usize) { + let count = black_box(run()); + let mut samples = [0.0; 7]; + for sample in &mut samples { + let start = Instant::now(); + for _ in 0..10 { + black_box(run()); + } + *sample = start.elapsed().as_secs_f64() * 100_000.0; + } + samples.sort_by(f64::total_cmp); + (samples[3], count) +} + +fn benchmark() { + for jagged in [false, true] { + let paths: Vec>> = (0..16) + .map(|row| { + (0..96) + .map(|j| { + let x = j as f64 * 2048.0; + let y = row as f64 * 32768.0 + + if jagged { + if j % 2 == 0 { 0.0 } else { 4096.0 } + } else { + libm::sin(j as f64 * 0.18) * 4096.0 + }; + IntPoint::new(I::from_rounded_float(x), I::from_rounded_float(y)) + }) + .collect() + }) + .collect(); + let vectors: Vec<_> = paths + .iter() + .flat_map(|p| p.windows(2).map(|pair| pair[1] - pair[0])) + .collect(); + for math in [MathMode::Integer, MathMode::Float] { + let (us, _) = measure(|| { + for &v in &vectors { + match math { + MathMode::Integer => { + black_box(>::normalize(black_box(v))); + } + MathMode::Float => { + black_box(>::normalize(black_box(v))); + } + } + } + vectors.len() + }); + println!("i{} jagged={jagged} normalize {math:?}: {us:.2} us", I::BITS); + } + for join in [ + IntLineJoin::Bevel, + IntLineJoin::Miter(Angle::from_radians(0.1)), + IntLineJoin::Round(ArcOptions::default()), + ] { + for math in [MathMode::Integer, MathMode::Float] { + let style = IntStrokeStyle::new(I::from_rounded_float(512.0)) + .math(math) + .line_join(join) + .start_cap(IntLineCap::Round(ArcOptions::default())) + .end_cap(IntLineCap::Square); + for full in [false, true] { + let (us, count) = measure(|| { + let mut overlay = build_stroke_overlay_iter( + black_box(&paths).iter().map(|p| p.iter().copied()), + black_box(&style), + false, + Default::default(), + ); + if full { + overlay + .overlay(OverlayRule::Subject, FillRule::Positive) + .iter() + .flatten() + .map(Vec::len) + .sum() + } else { + overlay.segments.len() + } + }); + println!( + "i{} jagged={jagged} join={join:?} full={full} {math:?}: {us:.2} us, count={count}", + I::BITS + ); + } + } + } + } +} + +#[test] +#[ignore = "manual timing: cargo test --release --lib benchmark_math_modes -- --ignored --nocapture"] +fn benchmark_math_modes() { + benchmark::(); + benchmark::(); +} diff --git a/iOverlay/src/mesh/int/stroke/builder.rs b/iOverlay/src/mesh/int/stroke/builder.rs index b60d6434..27b82075 100644 --- a/iOverlay/src/mesh/int/stroke/builder.rs +++ b/iOverlay/src/mesh/int/stroke/builder.rs @@ -1,7 +1,11 @@ use crate::mesh::int::{ - arc::{ArcBuilder, ArcDirection}, + arc::ArcDirection, join::Join, - math::{point, scaled_point, vector}, + math::{ + backend::{ArcMath, MeshMath}, + integer::IntegerMath, + point, + }, style::{IntLineCap, IntStrokeStyle}, }; use crate::mesh::subject::SubjectSegments; @@ -22,9 +26,9 @@ struct Section { } impl Section { - fn new(a: IntPoint, b: IntPoint, radius: I) -> Self { - let dir = crate::mesh::int::math::direction(b - a).expect("unique section"); - let v = dir.scale(radius); + fn new>(a: IntPoint, b: IntPoint, radius: I) -> Self { + let dir = M::normalize(b - a).expect("unique section"); + let v = M::scale(dir, radius); Self { a, b, @@ -42,18 +46,18 @@ impl Section { } } -enum Cap { +enum Cap> { Butt, Square, - Round(ArcBuilder), + Round(M::Arc), Custom(alloc::rc::Rc<[IntPoint]>), } -impl Cap { +impl> Cap { fn new(cap: &IntLineCap) -> Self { match cap { IntLineCap::Butt => Self::Butt, IntLineCap::Square => Self::Square, - IntLineCap::Round(options) => Self::Round(ArcBuilder::new(*options)), + IntLineCap::Round(options) => Self::Round(M::Arc::new(*options)), IntLineCap::Custom(points) => Self::Custom(points.clone()), } } @@ -73,30 +77,26 @@ impl Cap { let mut previous = from; match self { Self::Round(arc) => { - let a = crate::mesh::int::math::direction(from - center).expect("positive radius"); - let b = crate::mesh::int::math::direction(to - center).expect("positive radius"); + let a = M::normalize(from - center).expect("positive radius"); + let b = M::normalize(to - center).expect("positive radius"); for &dir in arc.build(a, b, ArcDirection::Counterclockwise) { - let next = scaled_point(center, dir, radius); + let offset = M::scale(dir, radius); + let next = point(center, offset.x, offset.y); segments.push_non_degenerate(previous, next); previous = next; } } Self::Square => { - let v = outward.scale(radius); + let v = M::scale(outward, radius); for next in [point(from, v.x, v.y), point(to, v.x, v.y)] { segments.push_non_degenerate(previous, next); previous = next; } } Self::Custom(points) => { - let v = vector(outward); - let shift = UnitIntVector::::DENOMINATOR.ilog2(); for p in points.iter() { - // Both products and their sum fit in Wide. Round once so - // the rotated cap stays within its conservative bounds. - let x = (v.x * p.x.to_wide() - v.y * p.y.to_wide()).shr_round(shift); - let y = (v.y * p.x.to_wide() + v.x * p.y.to_wide()).shr_round(shift); - let next = point(center, x, y); + let v = M::rotate(outward, *p); + let next = point(center, v.x, v.y); segments.push_non_degenerate(previous, next); previous = next; } @@ -107,14 +107,14 @@ impl Cap { } } -pub(super) struct StrokeBuilder { +pub(super) struct StrokeBuilder = IntegerMath> { radius: I, - join: Join, - start: Cap, - end: Cap, + join: Join, + start: Cap, + end: Cap, sections: Vec>, } -impl StrokeBuilder { +impl> StrokeBuilder { pub(super) fn radius(style: &IntStrokeStyle) -> I { I::from_wide((style.width.max(I::ZERO).to_wide() + I::Wide::ONE) / I::Wide::TWO) } @@ -139,9 +139,11 @@ impl StrokeBuilder { .unwrap_or(I::Wide::ZERO), _ => radius.to_wide(), }; - Join::padding(style.join, radius) - .max(cap_padding(&style.start_cap)) - .max(cap_padding(&style.end_cap)) + M::guard_padding( + Join::::padding(style.join, radius) + .max(cap_padding(&style.start_cap)) + .max(cap_padding(&style.end_cap)), + ) } pub(super) fn new(style: &IntStrokeStyle) -> Self { Self { @@ -172,12 +174,13 @@ impl StrokeBuilder { let mut previous = first; for next in path { if previous != next { - self.sections.push(Section::new(previous, next, self.radius)); + self.sections.push(Section::new::(previous, next, self.radius)); previous = next; } } if closed && previous != first { - self.sections.push(Section::new(previous, first, self.radius)); + self.sections + .push(Section::new::(previous, first, self.radius)); } if self.sections.is_empty() { return; @@ -193,7 +196,7 @@ impl StrokeBuilder { if closed { self.add_join(last, first, segments); } else { - let backward = crate::mesh::int::math::direction(first.a - first.b).unwrap(); + let backward = M::normalize(first.a - first.b).unwrap(); self.start.add( first.a, first.a_left, @@ -214,8 +217,8 @@ impl StrokeBuilder { ( b.a_left, a.b_left, - crate::mesh::int::math::direction(b.a - b.b).unwrap(), - crate::mesh::int::math::direction(a.a - a.b).unwrap(), + M::normalize(b.a - b.b).unwrap(), + M::normalize(a.a - a.b).unwrap(), ) }; if cross >= I::Wide::ZERO { @@ -270,7 +273,7 @@ mod tests { .start_cap(cap.clone()) .end_cap(cap.clone()) .line_join(join); - let mut builder = StrokeBuilder::new(&style); + let mut builder = StrokeBuilder::::new(&style); for closed in [false, true] { for end in [IntPoint::new(10, 10), IntPoint::new(0, 0), IntPoint::new(20, 1)] { let path = [ diff --git a/iOverlay/src/mesh/int/stroke/mod.rs b/iOverlay/src/mesh/int/stroke/mod.rs index cb964e34..5c5beb80 100644 --- a/iOverlay/src/mesh/int/stroke/mod.rs +++ b/iOverlay/src/mesh/int/stroke/mod.rs @@ -1,2 +1,5 @@ mod builder; pub mod offset; + +#[cfg(test)] +mod bench; diff --git a/iOverlay/src/mesh/int/stroke/offset.rs b/iOverlay/src/mesh/int/stroke/offset.rs index 682ca0ec..b6b1ca51 100644 --- a/iOverlay/src/mesh/int/stroke/offset.rs +++ b/iOverlay/src/mesh/int/stroke/offset.rs @@ -5,8 +5,10 @@ use crate::core::{ overlay::{IntOverlayOptions, Overlay}, overlay_rule::OverlayRule, }; +use crate::mesh::int::math::{backend::MeshMath, float::FloatMath, integer::IntegerMath}; use crate::mesh::int::outline::offset::IntOutlineError; use crate::mesh::int::style::IntStrokeStyle; +use crate::mesh::math::MathMode; use alloc::vec::Vec; use i_float::int::{point::IntPoint, rect::IntRect}; use i_shape::{ @@ -31,7 +33,7 @@ pub type IntStrokeError = IntOutlineError; pub trait IntStrokeOffset: IntShapeResource { fn validate_stroke(&self, style: &IntStrokeStyle) -> Result<(), IntStrokeError> { if let Some(rect) = IntRect::with_iter(self.iter_paths().flatten()) { - if !crate::mesh::int::bounds::expanded_is_safe(rect, StrokeBuilder::padding(style)) { + if !crate::mesh::int::bounds::expanded_is_safe(rect, stroke_padding(style)) { return Err(IntStrokeError::CoordinateOutOfRange); } } @@ -104,10 +106,39 @@ where Paths: IntoIterator, Path: IntoIterator>, { - let mut builder = StrokeBuilder::new(style); + match style.math { + MathMode::Integer => { + build_stroke_overlay_with_math::(paths, style, closed, options) + } + MathMode::Float => { + build_stroke_overlay_with_math::(paths, style, closed, options) + } + } +} + +fn stroke_padding(style: &IntStrokeStyle) -> I::Wide { + match style.math { + MathMode::Integer => StrokeBuilder::::padding(style), + MathMode::Float => StrokeBuilder::::padding(style), + } +} + +fn build_stroke_overlay_with_math( + paths: Paths, + style: &IntStrokeStyle, + closed: bool, + options: IntOverlayOptions, +) -> Overlay +where + I: OverlayInt, + M: MeshMath, + Paths: IntoIterator, + Path: IntoIterator>, +{ + let mut builder = StrokeBuilder::::new(style); let mut segments = Vec::new(); #[cfg(debug_assertions)] - let padding = StrokeBuilder::padding(style); + let padding = StrokeBuilder::::padding(style); for path in paths { #[cfg(debug_assertions)] let path = path.into_iter().inspect(|point| { @@ -145,28 +176,30 @@ mod tests { vec![IntPoint::new(4096, -4096), IntPoint::new(4096, 4096)], vec![], ]; - let style = IntStrokeStyle::new(1024); - for closed in [false, true] { - let expected = paths.stroke(&style, closed).unwrap(); - let visited = Cell::new(0); - let visited_ref = &visited; - let mut expected_visited = 0; - let iter = paths.clone().into_iter().map(move |path| { - // Finish each path before requesting the next one. - assert_eq!(visited_ref.get(), expected_visited); - expected_visited += path.len(); - let mut points = path.into_iter(); - // A single-pass point iterator; no Clone or slice access required. - core::iter::from_fn(move || { - let point = points.next()?; - visited_ref.set(visited_ref.get() + 1); - Some(point) - }) - }); - let actual = build_stroke_overlay_iter(iter, &style, closed, Default::default()) - .overlay(OverlayRule::Subject, FillRule::Positive); - assert_eq!(actual, expected); - assert_eq!(visited.get(), paths.iter().map(Vec::len).sum::()); + for math in [MathMode::Integer, MathMode::Float] { + let style = IntStrokeStyle::new(1024).math(math); + for closed in [false, true] { + let expected = paths.stroke(&style, closed).unwrap(); + let visited = Cell::new(0); + let visited_ref = &visited; + let mut expected_visited = 0; + let iter = paths.clone().into_iter().map(move |path| { + // Finish each path before requesting the next one. + assert_eq!(visited_ref.get(), expected_visited); + expected_visited += path.len(); + let mut points = path.into_iter(); + // A single-pass point iterator; no Clone or slice access required. + core::iter::from_fn(move || { + let point = points.next()?; + visited_ref.set(visited_ref.get() + 1); + Some(point) + }) + }); + let actual = build_stroke_overlay_iter(iter, &style, closed, Default::default()) + .overlay(OverlayRule::Subject, FillRule::Positive); + assert_eq!(actual, expected); + assert_eq!(visited.get(), paths.iter().map(Vec::len).sum::()); + } } } diff --git a/iOverlay/src/mesh/int/style.rs b/iOverlay/src/mesh/int/style.rs index 14bbfcf6..951ef7ea 100644 --- a/iOverlay/src/mesh/int/style.rs +++ b/iOverlay/src/mesh/int/style.rs @@ -1,4 +1,5 @@ use super::arc::ArcOptions; +use crate::mesh::math::MathMode; use i_float::int::angle::Angle; use i_float::int::number::int::IntNumber; @@ -72,6 +73,8 @@ pub struct IntStrokeStyle { pub start_cap: IntLineCap, pub end_cap: IntLineCap, pub join: IntLineJoin, + /// Arithmetic for stroke construction. Float mode may change rounded output. + pub math: MathMode, } impl IntStrokeStyle { @@ -81,8 +84,15 @@ impl IntStrokeStyle { start_cap: IntLineCap::Butt, end_cap: IntLineCap::Butt, join: IntLineJoin::Bevel, + math: MathMode::Integer, } } + /// Selects construction arithmetic; coordinates and boolean operations stay integer. + pub fn math(mut self, math: MathMode) -> Self { + self.math = math; + self + } + pub fn width(mut self, width: I) -> Self { self.width = width; self diff --git a/iOverlay/src/mesh/math.rs b/iOverlay/src/mesh/math.rs new file mode 100644 index 00000000..6d04c931 --- /dev/null +++ b/iOverlay/src/mesh/math.rs @@ -0,0 +1,14 @@ +/// Arithmetic used to construct stroke offsets, joins, and caps. +/// +/// Both modes retain integer coordinates and use the same integer boolean engine. +/// They can produce different rounded vertices and arc tessellations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MathMode { + /// Existing fixed-point directions and integer trigonometry. + #[default] + Integer, + /// Experimental f64 normalization and trigonometry. Directions are stored + /// as UnitIntVector, then scaled with integer arithmetic; cross-platform bitwise + /// reproducibility is not promised. Arc rotation_precision is ignored. + Float, +} diff --git a/iOverlay/src/mesh/mod.rs b/iOverlay/src/mesh/mod.rs index f925c139..e3cec929 100644 --- a/iOverlay/src/mesh/mod.rs +++ b/iOverlay/src/mesh/mod.rs @@ -1,5 +1,6 @@ pub mod float; pub mod int; +pub mod math; mod overlay; mod subject; mod uniq_iter; diff --git a/iOverlay/tests/stroke_cap_precision_tests.rs b/iOverlay/tests/stroke_cap_precision_tests.rs index a7bf2d41..b1e1ec68 100644 --- a/iOverlay/tests/stroke_cap_precision_tests.rs +++ b/iOverlay/tests/stroke_cap_precision_tests.rs @@ -1,54 +1,71 @@ use i_overlay::float::overlay::OverlayOptions; use i_overlay::mesh::float::stroke::offset::StrokeOffset; use i_overlay::mesh::float::style::{LineCap, StrokeStyle}; +use i_overlay::mesh::math::MathMode; #[test] fn round_caps_survive_coarse_integer_grid() { - let path = [[0.0, 0.0], [10.0, 0.0]]; - let style = StrokeStyle::new(4.0) - .start_cap(LineCap::Round(0.01)) - .end_cap(LineCap::Round(0.01)); - let output = path.stroke_fixed_scale(style, false, 1.0).unwrap(); - assert_eq!(output.len(), 1); - assert_eq!(output[0].len(), 1); - assert!(output[0][0].len() >= 4); + for math in [MathMode::Integer, MathMode::Float] { + let path = [[0.0, 0.0], [10.0, 0.0]]; + let style = StrokeStyle::new(4.0) + .math(math) + .start_cap(LineCap::Round(0.01)) + .end_cap(LineCap::Round(0.01)); + let output = path.stroke_fixed_scale(style, false, 1.0).unwrap(); + assert_eq!(output.len(), 1); + assert_eq!(output[0].len(), 1); + assert!(output[0][0].len() >= 4); + } } #[test] fn round_caps_survive_coarse_grid_without_cleanup() { - let path = [[0.0, 0.0], [10.0, 0.0]]; - let style = StrokeStyle::new(4.0) - .start_cap(LineCap::Round(0.01)) - .end_cap(LineCap::Round(0.01)); - let mut options = OverlayOptions::default(); - options.clean_result = false; - let output = path - .stroke_custom_fixed_scale(style, false, options, 1.0) - .unwrap(); - assert_eq!(output.len(), 1); + for math in [MathMode::Integer, MathMode::Float] { + let path = [[0.0, 0.0], [10.0, 0.0]]; + let style = StrokeStyle::new(4.0) + .math(math) + .start_cap(LineCap::Round(0.01)) + .end_cap(LineCap::Round(0.01)); + let mut options = OverlayOptions::default(); + options.clean_result = false; + let output = path + .stroke_custom_fixed_scale(style, false, options, 1.0) + .unwrap(); + assert_eq!(output.len(), 1); + } } #[test] fn coarse_butt_and_square_caps_and_fine_round_caps_control() { - let path = [[0.0, 0.0], [10.0, 0.0]]; - for (cap, scale) in [ - (LineCap::Butt, 1.0), - (LineCap::Square, 1.0), - (LineCap::Round(0.01), 100.0), - ] { - let style = StrokeStyle::new(4.0).start_cap(cap.clone()).end_cap(cap.clone()); - let output = path.stroke_fixed_scale(style, false, scale).unwrap(); - assert_eq!(output.len(), 1, "cap={cap:?}, scale={scale}"); + for math in [MathMode::Integer, MathMode::Float] { + let path = [[0.0, 0.0], [10.0, 0.0]]; + for (cap, scale) in [ + (LineCap::Butt, 1.0), + (LineCap::Square, 1.0), + (LineCap::Round(0.01), 100.0), + ] { + let style = StrokeStyle::new(4.0) + .math(math) + .start_cap(cap.clone()) + .end_cap(cap.clone()); + let output = path.stroke_fixed_scale(style, false, scale).unwrap(); + assert_eq!(output.len(), 1, "cap={cap:?}, scale={scale}"); + } } } #[test] fn custom_cap_without_repeated_points_control() { - let path = [[0.0, 0.0], [10.0, 0.0]]; - let cap = LineCap::Custom(std::rc::Rc::from([[1.0, -1.0], [1.0, 1.0]])); - let style = StrokeStyle::new(4.0).start_cap(cap.clone()).end_cap(cap); - let output = path.stroke(style, false); - assert_eq!(output.len(), 1); + for math in [MathMode::Integer, MathMode::Float] { + let path = [[0.0, 0.0], [10.0, 0.0]]; + let cap = LineCap::Custom(std::rc::Rc::from([[1.0, -1.0], [1.0, 1.0]])); + let style = StrokeStyle::new(4.0) + .math(math) + .start_cap(cap.clone()) + .end_cap(cap); + let output = path.stroke(style, false); + assert_eq!(output.len(), 1); + } } #[test] diff --git a/iOverlay/tests/stroke_coverage_tests.rs b/iOverlay/tests/stroke_coverage_tests.rs index 2f613225..1df61d37 100644 --- a/iOverlay/tests/stroke_coverage_tests.rs +++ b/iOverlay/tests/stroke_coverage_tests.rs @@ -4,6 +4,7 @@ use i_overlay::mesh::float::style::OutlineStyle; use i_overlay::mesh::float::style::{LineCap, LineJoin, StrokeStyle}; use i_overlay::mesh::float::variable_stroke::offset::VariableStrokeOffset; use i_overlay::mesh::float::variable_stroke::{StrokeVertex, VariableStrokeStyle}; +use i_overlay::mesh::math::MathMode; fn contains(shapes: &[Vec>], p: [f64; 2]) -> bool { shapes.iter().any(|shape| { @@ -44,49 +45,58 @@ fn assert_vertex_disks_covered(shapes: &[Vec>], path: &[StrokeVert #[test] fn round_strokes_cover_vertex_disks() { - let mut seed = 0x37a2_b951_d477_1011_u64; - let mut next = || { - seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); - ((seed >> 32) % 21) as f64 - 10.0 - }; - for case in 0..2000 { - let path: Vec<_> = (0..3 + case % 8).map(|_| [next(), next()]).collect(); + for math in [MathMode::Integer, MathMode::Float] { + let mut seed = 0x37a2_b951_d477_1011_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 21) as f64 - 10.0 + }; + for case in 0..2000 { + let path: Vec<_> = (0..3 + case % 8).map(|_| [next(), next()]).collect(); + let style = StrokeStyle::new(4.0) + .math(math) + .line_join(LineJoin::Round(0.05)) + .start_cap(LineCap::Round(0.05)) + .end_cap(LineCap::Round(0.05)); + let shapes = path.stroke_fixed_scale(style, false, 10000.0).unwrap(); + let vertices: Vec<_> = path.iter().map(|&p| StrokeVertex::new(p, 4.0)).collect(); + assert_vertex_disks_covered(&shapes, &vertices, case); + } + } +} + +#[test] +fn round_join_covers_a_reversing_vertex() { + for math in [MathMode::Integer, MathMode::Float] { + let path = [[0.0, 0.0], [10.0, 0.0], [0.0, 0.0]]; let style = StrokeStyle::new(4.0) + .math(math) .line_join(LineJoin::Round(0.05)) .start_cap(LineCap::Round(0.05)) .end_cap(LineCap::Round(0.05)); let shapes = path.stroke_fixed_scale(style, false, 10000.0).unwrap(); - let vertices: Vec<_> = path.iter().map(|&p| StrokeVertex::new(p, 4.0)).collect(); - assert_vertex_disks_covered(&shapes, &vertices, case); + assert!( + contains(&shapes, [11.5, 0.0]), + "round join must cover the turn at x=10" + ); } } -#[test] -fn round_join_covers_a_reversing_vertex() { - let path = [[0.0, 0.0], [10.0, 0.0], [0.0, 0.0]]; - let style = StrokeStyle::new(4.0) - .line_join(LineJoin::Round(0.05)) - .start_cap(LineCap::Round(0.05)) - .end_cap(LineCap::Round(0.05)); - let shapes = path.stroke_fixed_scale(style, false, 10000.0).unwrap(); - assert!( - contains(&shapes, [11.5, 0.0]), - "round join must cover the turn at x=10" - ); -} - #[test] fn round_join_covers_a_diagonal_reversing_vertex() { - let path = [[0.0, 0.0], [6.0, -8.0], [0.0, 0.0]]; - let style = StrokeStyle::new(4.0) - .line_join(LineJoin::Round(0.05)) - .start_cap(LineCap::Round(0.05)) - .end_cap(LineCap::Round(0.05)); - let shapes = path.stroke_fixed_scale(style, false, 10000.0).unwrap(); - assert!( - contains(&shapes, [6.9, -9.2]), - "round join must cover the diagonal turn" - ); + for math in [MathMode::Integer, MathMode::Float] { + let path = [[0.0, 0.0], [6.0, -8.0], [0.0, 0.0]]; + let style = StrokeStyle::new(4.0) + .math(math) + .line_join(LineJoin::Round(0.05)) + .start_cap(LineCap::Round(0.05)) + .end_cap(LineCap::Round(0.05)); + let shapes = path.stroke_fixed_scale(style, false, 10000.0).unwrap(); + assert!( + contains(&shapes, [6.9, -9.2]), + "round join must cover the diagonal turn" + ); + } } #[test] diff --git a/iOverlay/tests/stroke_direction_tests.rs b/iOverlay/tests/stroke_direction_tests.rs index 9ae085c9..defff483 100644 --- a/iOverlay/tests/stroke_direction_tests.rs +++ b/iOverlay/tests/stroke_direction_tests.rs @@ -1,5 +1,6 @@ use i_overlay::mesh::float::stroke::offset::StrokeOffset; use i_overlay::mesh::float::style::{LineJoin, StrokeStyle}; +use i_overlay::mesh::math::MathMode; fn canonical(shapes: Vec>>) -> Vec>> { let mut shapes: Vec>> = shapes @@ -37,15 +38,17 @@ fn reversing_path_preserves_bevel_and_miter_strokes() { let mut reversed = path.clone(); reversed.reverse(); for closed in [false, true] { - for join in [LineJoin::Bevel, LineJoin::Miter(0.2)] { - let style = StrokeStyle::new(1.25).line_join(join.clone()); - let forward = path.stroke_fixed_scale(style.clone(), closed, 1000.0).unwrap(); - let backward = reversed.stroke_fixed_scale(style, closed, 1000.0).unwrap(); - assert_eq!( - canonical(forward), - canonical(backward), - "case={case}, closed={closed}, join={join:?}, path={path:?}" - ); + for math in [MathMode::Integer, MathMode::Float] { + for join in [LineJoin::Bevel, LineJoin::Miter(0.2)] { + let style = StrokeStyle::new(1.25).math(math).line_join(join.clone()); + let forward = path.stroke_fixed_scale(style.clone(), closed, 1000.0).unwrap(); + let backward = reversed.stroke_fixed_scale(style, closed, 1000.0).unwrap(); + assert_eq!( + canonical(forward), + canonical(backward), + "case={case}, math={math:?}, closed={closed}, join={join:?}, path={path:?}" + ); + } } } } diff --git a/iOverlay/tests/stroke_math_tests.rs b/iOverlay/tests/stroke_math_tests.rs new file mode 100644 index 00000000..a7e679dd --- /dev/null +++ b/iOverlay/tests/stroke_math_tests.rs @@ -0,0 +1,133 @@ +use i_float::int::angle::Angle; +use i_float::int::point::IntPoint; +use i_overlay::mesh::float::{ + stroke::offset::StrokeOffset, + style::{LineCap, LineJoin, StrokeStyle}, +}; +use i_overlay::mesh::int::{ + arc::ArcOptions, + stroke::offset::IntStrokeOffset, + style::{IntLineCap, IntLineJoin, IntStrokeStyle}, +}; +use i_overlay::mesh::math::MathMode; +use i_shape::flat::float::FloatFlatContoursBuffer; +use i_shape::int::area::Area; + +#[test] +fn integer_default_is_preserved() { + assert_eq!(IntStrokeStyle::new(1024).math, MathMode::Integer); + assert_eq!(StrokeStyle::<[f64; 2]>::new(1.0).math, MathMode::Integer); +} + +macro_rules! stroke_area { + ($name:ident, $int:ty) => { + #[test] + fn $name() { + let path = [IntPoint::<$int>::new(0, 0), IntPoint::new(2400, 3200)]; + for math in [MathMode::Integer, MathMode::Float] { + for (cap, expected) in [ + (IntLineCap::Butt, 1600000.0), + (IntLineCap::Square, 1760000.0), + ] { + let style = IntStrokeStyle::new(400) + .math(math) + .start_cap(cap.clone()) + .end_cap(cap); + path.validate_stroke(&style).unwrap(); + let shapes = path.stroke(&style, false).unwrap(); + assert_eq!(shapes.len(), 1); + let actual = shapes.area() as f64; + assert!( + (actual - expected).abs() < 12000.0, + "{math:?}: {actual} vs {expected}" + ); + } + } + } + }; +} +stroke_area!(diagonal_area_i16, i16); +stroke_area!(diagonal_area_i32, i32); +stroke_area!(diagonal_area_i64, i64); + +#[test] +fn float_math_preserves_local_geometry_at_large_i64_origins() { + let path = [ + IntPoint::new(0_i64, 0), + IntPoint::new(3000, 4000), + IntPoint::new(9000, 4000), + ]; + let shift = 1_i64 << 60; + let translated = path.map(|p| IntPoint::new(p.x + shift, p.y - shift)); + for join in [ + IntLineJoin::Bevel, + IntLineJoin::Miter(Angle::from_radians(0.1)), + IntLineJoin::Round(ArcOptions::default()), + ] { + let style = IntStrokeStyle::new(400) + .math(MathMode::Float) + .line_join(join) + .start_cap(IntLineCap::Round(ArcOptions::default())) + .end_cap(IntLineCap::Square); + let expected = path.stroke(&style, false).unwrap(); + let mut actual = translated.stroke(&style, false).unwrap(); + for p in actual.iter_mut().flatten().flatten() { + p.x -= shift; + p.y += shift; + } + assert_eq!(actual, expected); + } +} + +#[test] +fn both_modes_support_lazy_float_input_and_reused_flat_output() { + let paths = vec![ + vec![], + vec![[0.0, 0.0], [3.0, 4.0], [3.0, 4.0], [7.0, 2.0]], + vec![[1.0, 2.0], [5.0, 0.0]], + ]; + let mut output = FloatFlatContoursBuffer::default(); + for math in [MathMode::Integer, MathMode::Float] { + for closed in [false, true] { + for join in [LineJoin::Bevel, LineJoin::Miter(0.1), LineJoin::Round(0.05)] { + let style = StrokeStyle::new(1.0) + .math(math) + .line_join(join) + .start_cap(LineCap::Round(0.05)) + .end_cap(LineCap::Square); + let expected = paths.stroke_fixed_scale(style.clone(), closed, 10000.0).unwrap(); + paths + .stroke_fixed_scale_into(style.clone(), closed, 10000.0, &mut output) + .unwrap(); + let contours: Vec<_> = expected.iter().flatten().collect(); + assert_eq!(output.ranges.len(), contours.len()); + for (range, contour) in output.ranges.iter().zip(contours) { + assert_eq!(&output.points[range.clone()], contour); + } + let empty: Vec<[f64; 2]> = vec![]; + empty + .stroke_fixed_scale_into(style, closed, 10000.0, &mut output) + .unwrap(); + assert!(output.points.is_empty() && output.ranges.is_empty()); + } + } + } +} + +#[test] +fn asymmetric_custom_end_cap_is_included_in_automatic_bounds() { + let path = [[0.0, 0.0], [1.0, 0.0]]; + for math in [MathMode::Integer, MathMode::Float] { + let style = StrokeStyle::new(2.0).math(math).end_cap(LineCap::Custom( + vec![[0.0, -1.0], [1000.0, 0.0], [0.0, 1.0]].into(), + )); + let shapes = path.stroke(style, false); + let max_x = shapes + .iter() + .flatten() + .flatten() + .map(|p| p[0]) + .fold(f64::NEG_INFINITY, f64::max); + assert!((max_x - 1001.0).abs() < 0.01); + } +} From 92e857305747bb5a119095c8af66b0ab0332e8d7 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Wed, 16 Sep 2026 16:28:42 +0300 Subject: [PATCH 22/44] api delegate refactoring --- iOverlay/src/mesh/float/outline/offset.rs | 30 +++-------------------- iOverlay/src/mesh/float/stroke/offset.rs | 28 +++------------------ 2 files changed, 8 insertions(+), 50 deletions(-) diff --git a/iOverlay/src/mesh/float/outline/offset.rs b/iOverlay/src/mesh/float/outline/offset.rs index b4fbbd68..20685dc6 100644 --- a/iOverlay/src/mesh/float/outline/offset.rs +++ b/iOverlay/src/mesh/float/outline/offset.rs @@ -221,10 +221,7 @@ where style: &OutlineStyle, options: OverlayOptions, ) -> Shapes

{ - match OutlineSolver::::prepare(self, style) { - Some(solver) => solver.build(self, options), - None => vec![], - } + self.outline_custom_as::(style, options) } fn outline_custom_into( @@ -233,10 +230,7 @@ where options: OverlayOptions, output: &mut FloatFlatContoursBuffer

, ) { - match OutlineSolver::::prepare(self, style) { - Some(solver) => solver.build_into(self, options, output), - None => output.clear_and_reserve(0, 0), - } + self.outline_custom_into_as::(style, options, output) } fn outline_fixed_scale( @@ -262,13 +256,7 @@ where options: OverlayOptions, scale: P::Scalar, ) -> Result, FixedScaleOverlayError> { - let s = FixedScaleOverlayError::validate_scale(scale)?; - let mut solver = match OutlineSolver::::prepare(self, style) { - Some(solver) => solver, - None => return Ok(vec![]), - }; - solver.apply_scale(s)?; - Ok(solver.build(self, options)) + self.outline_custom_fixed_scale_as::(style, options, scale) } fn outline_custom_fixed_scale_into( @@ -278,17 +266,7 @@ where scale: P::Scalar, output: &mut FloatFlatContoursBuffer

, ) -> Result<(), FixedScaleOverlayError> { - let s = FixedScaleOverlayError::validate_scale(scale)?; - let mut solver = match OutlineSolver::::prepare(self, style) { - Some(solver) => solver, - None => { - output.clear_and_reserve(0, 0); - return Ok(()); - } - }; - solver.apply_scale(s)?; - solver.build_into(self, options, output); - Ok(()) + self.outline_custom_fixed_scale_into_as::(style, options, scale, output) } fn outline_as(&self, style: &OutlineStyle) -> Shapes

diff --git a/iOverlay/src/mesh/float/stroke/offset.rs b/iOverlay/src/mesh/float/stroke/offset.rs index 2c44fb88..1ae5c6d5 100644 --- a/iOverlay/src/mesh/float/stroke/offset.rs +++ b/iOverlay/src/mesh/float/stroke/offset.rs @@ -263,10 +263,7 @@ where is_closed_path: bool, options: OverlayOptions, ) -> Shapes

{ - match StrokeSolver::::prepare(self, style) { - Some(solver) => solver.build(self, is_closed_path, options), - None => vec![], - } + self.stroke_custom_as::(style, is_closed_path, options) } fn stroke_custom_into( @@ -276,10 +273,7 @@ where options: OverlayOptions, output: &mut FloatFlatContoursBuffer

, ) { - match StrokeSolver::::prepare(self, style) { - Some(solver) => solver.build_into(self, is_closed_path, options, output), - None => output.clear_and_reserve(0, 0), - } + self.stroke_custom_into_as::(style, is_closed_path, options, output) } fn stroke_fixed_scale( @@ -308,12 +302,7 @@ where options: OverlayOptions, scale: P::Scalar, ) -> Result, FixedScaleOverlayError> { - let mut solver = match StrokeSolver::::prepare(self, style) { - Some(solver) => solver, - None => return Ok(vec![]), - }; - solver.apply_scale(scale)?; - Ok(solver.build(self, is_closed_path, options)) + self.stroke_custom_fixed_scale_as::(style, is_closed_path, options, scale) } fn stroke_custom_fixed_scale_into( @@ -324,16 +313,7 @@ where scale: P::Scalar, output: &mut FloatFlatContoursBuffer

, ) -> Result<(), FixedScaleOverlayError> { - let mut solver = match StrokeSolver::::prepare(self, style) { - Some(solver) => solver, - None => { - output.clear_and_reserve(0, 0); - return Ok(()); - } - }; - solver.apply_scale(scale)?; - solver.build_into(self, is_closed_path, options, output); - Ok(()) + self.stroke_custom_fixed_scale_into_as::(style, is_closed_path, options, scale, output) } fn stroke_as(&self, style: StrokeStyle

, is_closed_path: bool) -> Shapes

From ec9caa70a3d91ca85104c22005f2fa75e7ad5919 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Wed, 16 Sep 2026 16:52:34 +0300 Subject: [PATCH 23/44] lazy iter --- iOverlay/src/mesh/float/outline/offset.rs | 25 ++- iOverlay/src/mesh/int/outline/build.rs | 193 ++++++++++++++++------ iOverlay/src/mesh/int/outline/mod.rs | 1 + 3 files changed, 156 insertions(+), 63 deletions(-) diff --git a/iOverlay/src/mesh/float/outline/offset.rs b/iOverlay/src/mesh/float/outline/offset.rs index 20685dc6..4e3e6dba 100644 --- a/iOverlay/src/mesh/float/outline/offset.rs +++ b/iOverlay/src/mesh/float/outline/offset.rs @@ -1,8 +1,10 @@ +use crate::core::fill_rule::FillRule; use crate::core::integer::OverlayInt; +use crate::core::overlay_rule::OverlayRule; use crate::float::overlay::OverlayOptions; use crate::float::scale::FixedScaleOverlayError; use crate::mesh::float::style::OutlineStyle; -use crate::mesh::int::outline::offset::IntOutlineOffset; +use crate::mesh::int::outline::build_outline_overlay_iter; use crate::mesh::int::style::IntOutlineStyle; use alloc::vec; use i_float::adapter::FloatPointAdapter; @@ -13,7 +15,7 @@ use i_float::int::number::int::IntNumber; use i_shape::base::data::Shapes; use i_shape::flat::buffer::FlatContoursBuffer; use i_shape::flat::float::FloatFlatContoursBuffer; -use i_shape::float::adapter::{ResourceToInt, ShapesToFloat}; +use i_shape::float::adapter::{ResourceToIntIter, ShapesToFloat}; use i_shape::float::despike::DeSpikeContour; use i_shape::float::simple::SimplifyContour; use i_shape::source::float::resource::ShapeResource; @@ -427,10 +429,10 @@ where fn build>(self, source: &S, options: OverlayOptions) -> Shapes

{ let preserve_output_collinear = options.preserve_output_collinear; let clean_result = options.clean_result; - let paths = source.to_int_paths(&self.adapter); - let shapes = paths - .outline_custom(&self.int_style(), options.int_with_adapter(&self.adapter)) - .expect("integer outline supports every float join"); + let iter_paths = source.iter_int_paths(&self.adapter); + let shapes = + build_outline_overlay_iter(iter_paths, &self.int_style(), options.int_with_adapter(&self.adapter)) + .overlay(OverlayRule::Subject, FillRule::Positive); if clean_result { let mut float = shapes.to_float(&self.adapter); @@ -453,15 +455,10 @@ where ) { let preserve_output_collinear = options.preserve_output_collinear; let clean_result = options.clean_result; - let paths = source.to_int_paths(&self.adapter); + let iter_paths = source.iter_int_paths(&self.adapter); let mut int_output = FlatContoursBuffer::::with_capacity(0); - paths - .outline_custom_into( - &self.int_style(), - options.int_with_adapter(&self.adapter), - &mut int_output, - ) - .expect("integer outline supports every float join"); + build_outline_overlay_iter(iter_paths, &self.int_style(), options.int_with_adapter(&self.adapter)) + .overlay_into(OverlayRule::Subject, FillRule::Positive, &mut int_output); let iter = int_output.points.iter().map(|p| self.adapter.int_to_float(p)); output.set_with_iter(iter, &int_output.ranges); diff --git a/iOverlay/src/mesh/int/outline/build.rs b/iOverlay/src/mesh/int/outline/build.rs index 3e46d4ef..233da275 100644 --- a/iOverlay/src/mesh/int/outline/build.rs +++ b/iOverlay/src/mesh/int/outline/build.rs @@ -7,11 +7,14 @@ use crate::core::fill_rule::FillRule; use crate::core::integer::OverlayInt; use crate::core::overlay::{ContourDirection, IntOverlayOptions, Overlay, ShapeType}; use crate::core::overlay_rule::OverlayRule; +use crate::mesh::int::bounds::expanded_is_safe; use crate::mesh::int::join::Join; use crate::mesh::int::style::IntOutlineStyle; use alloc::vec::Vec; use i_float::int::number::uint::UIntNumber; use i_float::int::number::wide_int::WideIntNumber; +use i_float::int::point::IntPoint; +use i_float::int::rect::IntRect; use i_shape::flat::buffer::FlatContoursBuffer; use i_shape::int::area::Area; use i_shape::source::int::resource::IntShapeResource; @@ -26,64 +29,156 @@ pub(super) trait BuildOutlineOverlay: IntShapeResource { self.validate_outline_bounds(style).is_ok(), "outline bounds exceed the safe coordinate range" ); - Ok(self.build_overlay_with_builders( + Ok(build_outline_overlay_iter( + self.iter_paths().map(|path| path.iter().copied()), + style, options, - OutlineBuilder::new(style.outer_offset, Join::::new(style.join)), - OutlineBuilder::new(style.inner_offset, Join::::new(style.join)), )) } +} - fn build_overlay_with_builders>( - &self, - options: IntOverlayOptions, - mut outer_builder: OutlineBuilder, - mut inner_builder: OutlineBuilder, - ) -> Overlay { - let mut overlay = Overlay::new_custom(0, options, Default::default()); - let mut contour_options = options; +impl + ?Sized> BuildOutlineOverlay for S {} + +/// Builds one outline overlay from paths consumed once, retaining only the current path. +pub(crate) fn build_outline_overlay_iter( + paths: Paths, + style: &IntOutlineStyle, + options: IntOverlayOptions, +) -> Overlay +where + I: OverlayInt, + Paths: IntoIterator, + Path: IntoIterator>, +{ + #[cfg(debug_assertions)] + let padding = Join::::padding(style.join, style.outer_offset) + .max(Join::::padding(style.join, style.inner_offset)); + + build_outline_overlay_with_builders( + paths, + options, + OutlineBuilder::new(style.outer_offset, Join::::new(style.join)), + OutlineBuilder::new(style.inner_offset, Join::::new(style.join)), + |point| { + #[cfg(debug_assertions)] + debug_assert!( + expanded_is_safe(IntRect::with_point(point), padding), + "outline bounds exceed the safe coordinate range" + ); + }, + ) +} + +fn build_outline_overlay_with_builders( + paths: Paths, + options: IntOverlayOptions, + mut outer_builder: OutlineBuilder, + mut inner_builder: OutlineBuilder, + mut validate_point: F, +) -> Overlay +where + I: OverlayInt, + Paths: IntoIterator, + Path: IntoIterator>, + J: JoinBuilder, + F: FnMut(IntPoint), +{ + let mut overlay = Overlay::new_custom(0, options, Default::default()); + let mut contour_options = options; - // Contours below the threshold can join into a larger surviving result. - contour_options.min_output_area = I::WideUInt::ZERO; + // Contours below the threshold can join into a larger surviving result. + contour_options.min_output_area = I::WideUInt::ZERO; - let mut contour_overlay = Overlay::new_custom(0, contour_options, Default::default()); - let mut segments = Vec::new(); - let mut extraction = BooleanExtractionBuffer::default(); - let mut contours = FlatContoursBuffer::default(); + let mut contour_overlay = Overlay::new_custom(0, contour_options, Default::default()); + let mut segments = Vec::new(); + let mut extraction = BooleanExtractionBuffer::default(); + let mut contours = FlatContoursBuffer::default(); + let mut path_buffer = Vec::new(); - for path in self.iter_paths() { - if path.len() < 3 { - continue; - } - let area = path.area_two(); - if area == I::Wide::ZERO { - continue; - } - let (builder, direction, fill) = if area > I::Wide::ZERO { - ( - &mut outer_builder, - ContourDirection::CounterClockwise, - FillRule::Positive, - ) - } else { - ( - &mut inner_builder, - ContourDirection::Clockwise, - FillRule::Negative, - ) - }; - segments.clear(); - builder.build(path, &mut segments); + for path in paths { + path_buffer.clear(); + path_buffer.extend(path.into_iter().inspect(|point| validate_point(*point))); + let path = path_buffer.as_slice(); + if path.len() < 3 { + continue; + } + let area = path.area_two(); + if area == I::Wide::ZERO { + continue; + } + let (builder, direction, fill) = if area > I::Wide::ZERO { + ( + &mut outer_builder, + ContourDirection::CounterClockwise, + FillRule::Positive, + ) + } else { + ( + &mut inner_builder, + ContourDirection::Clockwise, + FillRule::Negative, + ) + }; + segments.clear(); + builder.build(path, &mut segments); - contour_overlay.options.output_direction = direction; - contour_overlay.clear(); - contour_overlay.add_segments(&segments); - if let Some(graph) = contour_overlay.build_graph_view(fill) { - graph.extract_contours_into(OverlayRule::Subject, &mut extraction, &mut contours); - overlay.add_flat_buffer(&contours, ShapeType::Subject); - } + contour_overlay.options.output_direction = direction; + contour_overlay.clear(); + contour_overlay.add_segments(&segments); + if let Some(graph) = contour_overlay.build_graph_view(fill) { + graph.extract_contours_into(OverlayRule::Subject, &mut extraction, &mut contours); + overlay.add_flat_buffer(&contours, ShapeType::Subject); } - overlay } + overlay } -impl + ?Sized> BuildOutlineOverlay for S {} +#[cfg(test)] +mod tests { + use super::*; + use crate::mesh::int::outline::offset::IntOutlineOffset; + use alloc::vec; + use core::cell::Cell; + + #[test] + fn one_pass_paths_match_resource_outlines() { + let paths = vec![ + vec![], + vec![IntPoint::new(0, 0), IntPoint::new(0, 0)], + vec![ + IntPoint::new(0, 0), + IntPoint::new(0, 0), + IntPoint::new(8192, 0), + IntPoint::new(8192, 8192), + IntPoint::new(0, 8192), + ], + vec![ + IntPoint::new(2048, 2048), + IntPoint::new(2048, 6144), + IntPoint::new(6144, 6144), + IntPoint::new(6144, 2048), + ], + ]; + let style = IntOutlineStyle::new(1024); + let expected = paths.outline(&style).unwrap(); + + let visited = Cell::new(0); + let visited_ref = &visited; + let mut expected_visited = 0; + let iter = paths.clone().into_iter().map(move |path| { + assert_eq!(visited_ref.get(), expected_visited); + expected_visited += path.len(); + let mut points = path.into_iter(); + core::iter::from_fn(move || { + let point = points.next()?; + visited_ref.set(visited_ref.get() + 1); + Some(point) + }) + }); + + let actual = build_outline_overlay_iter(iter, &style, Default::default()) + .overlay(OverlayRule::Subject, FillRule::Positive); + assert_eq!(actual, expected); + assert_eq!(visited.get(), paths.iter().map(Vec::len).sum::()); + } +} diff --git a/iOverlay/src/mesh/int/outline/mod.rs b/iOverlay/src/mesh/int/outline/mod.rs index 49285e91..94ccf3f4 100644 --- a/iOverlay/src/mesh/int/outline/mod.rs +++ b/iOverlay/src/mesh/int/outline/mod.rs @@ -1,5 +1,6 @@ mod bounds; mod build; +pub(crate) use build::build_outline_overlay_iter; mod builder; mod builder_join; pub mod offset; From 815d81f716219e1d9589f29df39ad1c90cc986e0 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Wed, 16 Sep 2026 17:06:44 +0300 Subject: [PATCH 24/44] code clean --- iOverlay/src/mesh/float/outline/offset.rs | 22 ++++++++++++---------- iOverlay/src/mesh/float/stroke/offset.rs | 16 +++++++--------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/iOverlay/src/mesh/float/outline/offset.rs b/iOverlay/src/mesh/float/outline/offset.rs index 4e3e6dba..fd5d3441 100644 --- a/iOverlay/src/mesh/float/outline/offset.rs +++ b/iOverlay/src/mesh/float/outline/offset.rs @@ -293,9 +293,10 @@ where where I: OverlayInt + 'static, { - match OutlineSolver::::prepare(self, style) { - Some(solver) => solver.build(self, options), - None => vec![], + if let Some(solver) = OutlineSolver::::prepare(self, style) { + solver.build(self, options) + } else { + vec![] } } @@ -307,9 +308,10 @@ where ) where I: OverlayInt + 'static, { - match OutlineSolver::::prepare(self, style) { - Some(solver) => solver.build_into(self, options, output), - None => output.clear_and_reserve(0, 0), + if let Some(solver) = OutlineSolver::::prepare(self, style) { + solver.build_into(self, options, output) + } else { + output.clear_and_reserve(0, 0) } } @@ -429,9 +431,9 @@ where fn build>(self, source: &S, options: OverlayOptions) -> Shapes

{ let preserve_output_collinear = options.preserve_output_collinear; let clean_result = options.clean_result; - let iter_paths = source.iter_int_paths(&self.adapter); + let iter_int_paths = source.iter_int_paths(&self.adapter); let shapes = - build_outline_overlay_iter(iter_paths, &self.int_style(), options.int_with_adapter(&self.adapter)) + build_outline_overlay_iter(iter_int_paths, &self.int_style(), options.int_with_adapter(&self.adapter)) .overlay(OverlayRule::Subject, FillRule::Positive); if clean_result { @@ -455,9 +457,9 @@ where ) { let preserve_output_collinear = options.preserve_output_collinear; let clean_result = options.clean_result; - let iter_paths = source.iter_int_paths(&self.adapter); + let iter_int_paths = source.iter_int_paths(&self.adapter); let mut int_output = FlatContoursBuffer::::with_capacity(0); - build_outline_overlay_iter(iter_paths, &self.int_style(), options.int_with_adapter(&self.adapter)) + build_outline_overlay_iter(iter_int_paths, &self.int_style(), options.int_with_adapter(&self.adapter)) .overlay_into(OverlayRule::Subject, FillRule::Positive, &mut int_output); let iter = int_output.points.iter().map(|p| self.adapter.int_to_float(p)); output.set_with_iter(iter, &int_output.ranges); diff --git a/iOverlay/src/mesh/float/stroke/offset.rs b/iOverlay/src/mesh/float/stroke/offset.rs index 1ae5c6d5..7525ac14 100644 --- a/iOverlay/src/mesh/float/stroke/offset.rs +++ b/iOverlay/src/mesh/float/stroke/offset.rs @@ -16,7 +16,7 @@ use i_float::int::number::wide_int::WideIntNumber; use i_shape::base::data::Shapes; use i_shape::flat::buffer::FlatContoursBuffer; use i_shape::flat::float::FloatFlatContoursBuffer; -use i_shape::float::adapter::ShapesToFloat; +use i_shape::float::adapter::{ResourceToIntIter, ShapesToFloat}; use i_shape::float::despike::DeSpikeContour; use i_shape::float::simple::SimplifyContour; use i_shape::source::float::resource::ShapeResource; @@ -474,12 +474,11 @@ where return vec![]; } - let paths = source - .iter_paths() - .map(|path| path.iter().map(|p| self.adapter.float_to_int(p))); + let iter_int_paths = source.iter_int_paths(&self.adapter); + let style = self.style.to_int(&self.adapter); let shapes = build_stroke_overlay_iter( - paths, + iter_int_paths, &style, is_closed_path, options.int_with_adapter(&self.adapter), @@ -513,13 +512,12 @@ where return; } - let paths = source - .iter_paths() - .map(|path| path.iter().map(|p| self.adapter.float_to_int(p))); + let iter_int_paths = source.iter_int_paths(&self.adapter); + let style = self.style.to_int(&self.adapter); let mut int_output = FlatContoursBuffer::::with_capacity(0); build_stroke_overlay_iter( - paths, + iter_int_paths, &style, is_closed_path, options.int_with_adapter(&self.adapter), From 089e349c0c7da6774dfd1a96bffd2b453bb4c7af Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Wed, 16 Sep 2026 19:25:04 +0300 Subject: [PATCH 25/44] drop memory allocation --- iOverlay/src/core/extract.rs | 4 +- iOverlay/src/mesh/float/outline/offset.rs | 11 +- iOverlay/src/mesh/int/outline/build.rs | 191 ++++++++------------- iOverlay/src/mesh/int/outline/builder.rs | 7 +- iOverlay/src/mesh/int/outline/mod.rs | 2 +- iOverlay/src/mesh/int/outline/offset.rs | 14 +- iOverlay/src/mesh/int/outline/section.rs | 7 +- iOverlay/src/string/split.rs | 4 +- iOverlay/tests/int_outline_bounds_tests.rs | 19 +- 9 files changed, 102 insertions(+), 157 deletions(-) diff --git a/iOverlay/src/core/extract.rs b/iOverlay/src/core/extract.rs index 1989acf7..821d942e 100644 --- a/iOverlay/src/core/extract.rs +++ b/iOverlay/src/core/extract.rs @@ -16,7 +16,7 @@ use i_float::int::number::uint::UIntNumber; use i_float::int::number::wide_int::WideIntNumber; use i_float::int::point::IntPoint; use i_float::triangle::Triangle; -use i_shape::int::path::ContourExtension; +use i_shape::int::area::UnsafeArea; use i_shape::int::shape::{IntContour, IntShapes}; use i_shape::int::simple::Simplify; @@ -337,7 +337,7 @@ impl GraphContour for IntContour { if min_output_area == I::WideUInt::ZERO { return true; } - let area = self.unsafe_area(); + let area = self.iter().copied().unsafe_area(); let abs_area = area.unsigned_abs() >> 1; abs_area >= min_output_area diff --git a/iOverlay/src/mesh/float/outline/offset.rs b/iOverlay/src/mesh/float/outline/offset.rs index fd5d3441..3d06c1e7 100644 --- a/iOverlay/src/mesh/float/outline/offset.rs +++ b/iOverlay/src/mesh/float/outline/offset.rs @@ -4,7 +4,7 @@ use crate::core::overlay_rule::OverlayRule; use crate::float::overlay::OverlayOptions; use crate::float::scale::FixedScaleOverlayError; use crate::mesh::float::style::OutlineStyle; -use crate::mesh::int::outline::build_outline_overlay_iter; +use crate::mesh::int::outline::BuildOutlineOverlay; use crate::mesh::int::style::IntOutlineStyle; use alloc::vec; use i_float::adapter::FloatPointAdapter; @@ -432,9 +432,9 @@ where let preserve_output_collinear = options.preserve_output_collinear; let clean_result = options.clean_result; let iter_int_paths = source.iter_int_paths(&self.adapter); - let shapes = - build_outline_overlay_iter(iter_int_paths, &self.int_style(), options.int_with_adapter(&self.adapter)) - .overlay(OverlayRule::Subject, FillRule::Positive); + let shapes = iter_int_paths + .build_overlay(&self.int_style(), options.int_with_adapter(&self.adapter)) + .overlay(OverlayRule::Subject, FillRule::Positive); if clean_result { let mut float = shapes.to_float(&self.adapter); @@ -459,7 +459,8 @@ where let clean_result = options.clean_result; let iter_int_paths = source.iter_int_paths(&self.adapter); let mut int_output = FlatContoursBuffer::::with_capacity(0); - build_outline_overlay_iter(iter_int_paths, &self.int_style(), options.int_with_adapter(&self.adapter)) + iter_int_paths + .build_overlay(&self.int_style(), options.int_with_adapter(&self.adapter)) .overlay_into(OverlayRule::Subject, FillRule::Positive, &mut int_output); let iter = int_output.points.iter().map(|p| self.adapter.int_to_float(p)); output.set_with_iter(iter, &int_output.ranges); diff --git a/iOverlay/src/mesh/int/outline/build.rs b/iOverlay/src/mesh/int/outline/build.rs index 233da275..bc3e641a 100644 --- a/iOverlay/src/mesh/int/outline/build.rs +++ b/iOverlay/src/mesh/int/outline/build.rs @@ -1,136 +1,81 @@ -use super::bounds::OutlineBounds; use super::builder::OutlineBuilder; -use super::builder_join::JoinBuilder; -use super::offset::IntOutlineError; use crate::core::extract::BooleanExtractionBuffer; use crate::core::fill_rule::FillRule; use crate::core::integer::OverlayInt; use crate::core::overlay::{ContourDirection, IntOverlayOptions, Overlay, ShapeType}; use crate::core::overlay_rule::OverlayRule; -use crate::mesh::int::bounds::expanded_is_safe; use crate::mesh::int::join::Join; use crate::mesh::int::style::IntOutlineStyle; use alloc::vec::Vec; use i_float::int::number::uint::UIntNumber; use i_float::int::number::wide_int::WideIntNumber; use i_float::int::point::IntPoint; -use i_float::int::rect::IntRect; use i_shape::flat::buffer::FlatContoursBuffer; -use i_shape::int::area::Area; -use i_shape::source::int::resource::IntShapeResource; +use i_shape::int::area::IteratorArea; -pub(super) trait BuildOutlineOverlay: IntShapeResource { - fn build_overlay( - &self, - style: &IntOutlineStyle, - options: IntOverlayOptions, - ) -> Result, IntOutlineError> { - debug_assert!( - self.validate_outline_bounds(style).is_ok(), - "outline bounds exceed the safe coordinate range" - ); - Ok(build_outline_overlay_iter( - self.iter_paths().map(|path| path.iter().copied()), - style, - options, - )) - } +pub(crate) trait BuildOutlineOverlay: Sized { + fn build_overlay(self, style: &IntOutlineStyle, options: IntOverlayOptions) + -> Overlay; } -impl + ?Sized> BuildOutlineOverlay for S {} - -/// Builds one outline overlay from paths consumed once, retaining only the current path. -pub(crate) fn build_outline_overlay_iter( - paths: Paths, - style: &IntOutlineStyle, - options: IntOverlayOptions, -) -> Overlay +impl BuildOutlineOverlay for Paths where - I: OverlayInt, Paths: IntoIterator, - Path: IntoIterator>, + Path: ExactSizeIterator> + Clone, { - #[cfg(debug_assertions)] - let padding = Join::::padding(style.join, style.outer_offset) - .max(Join::::padding(style.join, style.inner_offset)); - - build_outline_overlay_with_builders( - paths, - options, - OutlineBuilder::new(style.outer_offset, Join::::new(style.join)), - OutlineBuilder::new(style.inner_offset, Join::::new(style.join)), - |point| { - #[cfg(debug_assertions)] - debug_assert!( - expanded_is_safe(IntRect::with_point(point), padding), - "outline bounds exceed the safe coordinate range" - ); - }, - ) -} + fn build_overlay( + self, + style: &IntOutlineStyle, + options: IntOverlayOptions, + ) -> Overlay { + let mut outer_builder = OutlineBuilder::new(style.outer_offset, Join::::new(style.join)); + let mut inner_builder = OutlineBuilder::new(style.inner_offset, Join::::new(style.join)); -fn build_outline_overlay_with_builders( - paths: Paths, - options: IntOverlayOptions, - mut outer_builder: OutlineBuilder, - mut inner_builder: OutlineBuilder, - mut validate_point: F, -) -> Overlay -where - I: OverlayInt, - Paths: IntoIterator, - Path: IntoIterator>, - J: JoinBuilder, - F: FnMut(IntPoint), -{ - let mut overlay = Overlay::new_custom(0, options, Default::default()); - let mut contour_options = options; + let mut overlay = Overlay::new_custom(0, options, Default::default()); + let mut contour_options = options; - // Contours below the threshold can join into a larger surviving result. - contour_options.min_output_area = I::WideUInt::ZERO; + // Contours below the threshold can join into a larger surviving result. + contour_options.min_output_area = I::WideUInt::ZERO; - let mut contour_overlay = Overlay::new_custom(0, contour_options, Default::default()); - let mut segments = Vec::new(); - let mut extraction = BooleanExtractionBuffer::default(); - let mut contours = FlatContoursBuffer::default(); - let mut path_buffer = Vec::new(); + let mut contour_overlay = Overlay::new_custom(0, contour_options, Default::default()); + let mut segments = Vec::new(); + let mut extraction = BooleanExtractionBuffer::default(); + let mut contours = FlatContoursBuffer::default(); - for path in paths { - path_buffer.clear(); - path_buffer.extend(path.into_iter().inspect(|point| validate_point(*point))); - let path = path_buffer.as_slice(); - if path.len() < 3 { - continue; - } - let area = path.area_two(); - if area == I::Wide::ZERO { - continue; - } - let (builder, direction, fill) = if area > I::Wide::ZERO { - ( - &mut outer_builder, - ContourDirection::CounterClockwise, - FillRule::Positive, - ) - } else { - ( - &mut inner_builder, - ContourDirection::Clockwise, - FillRule::Negative, - ) - }; - segments.clear(); - builder.build(path, &mut segments); + for path in self { + if path.len() < 3 { + continue; + } + let area = path.clone().area_two(); + if area == I::Wide::ZERO { + continue; + } + let (builder, direction, fill) = if area > I::Wide::ZERO { + ( + &mut outer_builder, + ContourDirection::CounterClockwise, + FillRule::Positive, + ) + } else { + ( + &mut inner_builder, + ContourDirection::Clockwise, + FillRule::Negative, + ) + }; + segments.clear(); + builder.build(path, &mut segments); - contour_overlay.options.output_direction = direction; - contour_overlay.clear(); - contour_overlay.add_segments(&segments); - if let Some(graph) = contour_overlay.build_graph_view(fill) { - graph.extract_contours_into(OverlayRule::Subject, &mut extraction, &mut contours); - overlay.add_flat_buffer(&contours, ShapeType::Subject); + contour_overlay.options.output_direction = direction; + contour_overlay.clear(); + contour_overlay.add_segments(&segments); + if let Some(graph) = contour_overlay.build_graph_view(fill) { + graph.extract_contours_into(OverlayRule::Subject, &mut extraction, &mut contours); + overlay.add_flat_buffer(&contours, ShapeType::Subject); + } } + overlay } - overlay } #[cfg(test)] @@ -141,10 +86,15 @@ mod tests { use core::cell::Cell; #[test] - fn one_pass_paths_match_resource_outlines() { + fn cloned_path_iterators_match_resource_outlines() { let paths = vec![ vec![], vec![IntPoint::new(0, 0), IntPoint::new(0, 0)], + vec![ + IntPoint::new(0, 0), + IntPoint::new(1024, 0), + IntPoint::new(2048, 0), + ], vec![ IntPoint::new(0, 0), IntPoint::new(0, 0), @@ -163,22 +113,23 @@ mod tests { let expected = paths.outline(&style).unwrap(); let visited = Cell::new(0); - let visited_ref = &visited; - let mut expected_visited = 0; - let iter = paths.clone().into_iter().map(move |path| { - assert_eq!(visited_ref.get(), expected_visited); - expected_visited += path.len(); - let mut points = path.into_iter(); - core::iter::from_fn(move || { - let point = points.next()?; - visited_ref.set(visited_ref.get() + 1); - Some(point) + let paths_visited = Cell::new(0); + let iter = paths.iter().map(|path| { + paths_visited.set(paths_visited.get() + 1); + path.iter().copied().inspect(|_| { + visited.set(visited.get() + 1); }) }); - let actual = build_outline_overlay_iter(iter, &style, Default::default()) + let actual = iter + .build_overlay(&style, Default::default()) .overlay(OverlayRule::Subject, FillRule::Positive); assert_eq!(actual, expected); - assert_eq!(visited.get(), paths.iter().map(Vec::len).sum::()); + assert_eq!(paths_visited.get(), paths.len()); + // Short paths are skipped; zero-area paths need only the area pass. + assert_eq!( + visited.get(), + paths[2].len() + 2 * (paths[3].len() + paths[4].len()) + ); } } diff --git a/iOverlay/src/mesh/int/outline/builder.rs b/iOverlay/src/mesh/int/outline/builder.rs index 0e41adba..08a4a65d 100644 --- a/iOverlay/src/mesh/int/outline/builder.rs +++ b/iOverlay/src/mesh/int/outline/builder.rs @@ -19,8 +19,11 @@ impl> OutlineBuilder { Self { offset, join_builder } } - pub(super) fn build(&mut self, path: &[IntPoint], segments: &mut Vec>) { - let Some(mut iter) = UniqueSegmentsIter::new(path.iter().copied()) else { + pub(super) fn build(&mut self, path: Path, segments: &mut Vec>) + where + Path: Iterator>, + { + let Some(mut iter) = UniqueSegmentsIter::new(path) else { return; }; let Some(first) = iter.next() else { diff --git a/iOverlay/src/mesh/int/outline/mod.rs b/iOverlay/src/mesh/int/outline/mod.rs index 94ccf3f4..31f14d8a 100644 --- a/iOverlay/src/mesh/int/outline/mod.rs +++ b/iOverlay/src/mesh/int/outline/mod.rs @@ -1,6 +1,6 @@ mod bounds; mod build; -pub(crate) use build::build_outline_overlay_iter; +pub(crate) use build::BuildOutlineOverlay; mod builder; mod builder_join; pub mod offset; diff --git a/iOverlay/src/mesh/int/outline/offset.rs b/iOverlay/src/mesh/int/outline/offset.rs index a639702b..0844dffc 100644 --- a/iOverlay/src/mesh/int/outline/offset.rs +++ b/iOverlay/src/mesh/int/outline/offset.rs @@ -35,8 +35,8 @@ impl core::error::Error for IntOutlineError {} /// Input and constructed coordinates must satisfy /// [`IntRect::is_in_safe_range`](i_float::int::rect::IntRect::is_in_safe_range). /// Use [`Self::validate_outline`] for an optional conservative bounds check. -/// Construction checks this precondition only in debug builds; release builds -/// trust the caller. Contour signed double areas must also fit in `I::Wide` +/// Construction trusts the caller to satisfy this precondition. +/// Contour signed double areas must also fit in `I::Wide` /// (including repeated winding). /// /// Uses [`fast_normalize`](i_float::int::vector::IntVector::fast_normalize) @@ -89,7 +89,10 @@ pub trait IntOutlineOffset: IntShapeResource { style: &IntOutlineStyle, options: IntOverlayOptions, ) -> Result, IntOutlineError> { - let mut overlay = self.build_overlay(style, options)?; + let mut overlay = self + .iter_paths() + .map(|path| path.iter().copied()) + .build_overlay(style, options); Ok(overlay.overlay(OverlayRule::Subject, FillRule::Positive)) } @@ -101,7 +104,10 @@ pub trait IntOutlineOffset: IntShapeResource { options: IntOverlayOptions, output: &mut FlatContoursBuffer, ) -> Result<(), IntOutlineError> { - let mut overlay = self.build_overlay(style, options)?; + let mut overlay = self + .iter_paths() + .map(|path| path.iter().copied()) + .build_overlay(style, options); overlay.overlay_into(OverlayRule::Subject, FillRule::Positive, output); Ok(()) } diff --git a/iOverlay/src/mesh/int/outline/section.rs b/iOverlay/src/mesh/int/outline/section.rs index 50dd2844..0fede1de 100644 --- a/iOverlay/src/mesh/int/outline/section.rs +++ b/iOverlay/src/mesh/int/outline/section.rs @@ -1,3 +1,4 @@ +use crate::mesh::int::math::point; use crate::mesh::uniq_iter::UniqueSegment; use i_float::int::number::int::IntNumber; use i_float::int::point::IntPoint; @@ -31,10 +32,8 @@ impl OffsetSection { let dx = scaled.y; let dy = -scaled.x; - let a_top = IntPoint::new(I::from_wide(a.x.to_wide() + dx), I::from_wide(a.y.to_wide() + dy)); - let b_top = IntPoint::new(I::from_wide(b.x.to_wide() + dx), I::from_wide(b.y.to_wide() + dy)); - - debug_assert!(a_top.is_in_safe_range() && b_top.is_in_safe_range()); + let a_top = point(a, dx, dy); + let b_top = point(b, dx, dy); Self { offset, diff --git a/iOverlay/src/string/split.rs b/iOverlay/src/string/split.rs index a1dcee3f..2da28dff 100644 --- a/iOverlay/src/string/split.rs +++ b/iOverlay/src/string/split.rs @@ -3,7 +3,7 @@ use i_float::int::number::int::IntNumber; use i_float::int::number::uint::UIntNumber; use i_float::int::number::wide_int::WideIntNumber; use i_float::int::point::IntPoint; -use i_shape::int::path::ContourExtension; +use i_shape::int::area::UnsafeArea; use i_shape::int::shape::IntContour; pub(super) trait Split { @@ -170,7 +170,7 @@ impl ValidateArea for IntContour { if min_area == I::WideUInt::ZERO { return true; } - let abs_area = self.unsafe_area().unsigned_abs() >> 1; + let abs_area = self.iter().copied().unsafe_area().unsigned_abs() >> 1; abs_area >= min_area } } diff --git a/iOverlay/tests/int_outline_bounds_tests.rs b/iOverlay/tests/int_outline_bounds_tests.rs index 6c8dc1f8..cdd00a8d 100644 --- a/iOverlay/tests/int_outline_bounds_tests.rs +++ b/iOverlay/tests/int_outline_bounds_tests.rs @@ -101,7 +101,7 @@ impl IntShapeResource for CountedResource<'_> { } #[test] -fn release_construction_does_not_repeat_the_bounds_pass() { +fn construction_does_not_repeat_the_bounds_pass() { let path = [ IntPoint::new(0, 0), IntPoint::new(8192, 0), @@ -117,20 +117,5 @@ fn release_construction_does_not_repeat_the_bounds_pass() { assert_eq!(source.traversals.get(), 1); source.traversals.set(0); assert_eq!(source.outline(&style).unwrap().len(), 1); - assert_eq!( - source.traversals.get(), - if cfg!(debug_assertions) { 2 } else { 1 } - ); -} - -#[cfg(debug_assertions)] -#[test] -#[should_panic(expected = "outline bounds exceed the safe coordinate range")] -fn debug_construction_asserts_the_bounds_precondition() { - let path = [ - IntPoint::new(0, 0), - IntPoint::new(i32::MAX, 0), - IntPoint::new(0, 8192), - ]; - let _ = path.outline(&IntOutlineStyle::new(0)); + assert_eq!(source.traversals.get(), 1); } From fe374b406b1d448009402f7ec981c0aacda3d28c Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Wed, 16 Sep 2026 21:38:52 +0300 Subject: [PATCH 26/44] refactor float math api --- iOverlay/Cargo.toml | 1 - iOverlay/src/mesh/int/arc.rs | 253 ++++------------------- iOverlay/src/mesh/int/arc/float.rs | 206 ++++++++++++++++++ iOverlay/src/mesh/int/arc/integer.rs | 225 ++++++++++++++++++++ iOverlay/src/mesh/int/arc/tests.rs | 119 +++++++++++ iOverlay/src/mesh/int/join.rs | 4 +- iOverlay/src/mesh/int/math/backend.rs | 7 +- iOverlay/src/mesh/int/math/float.rs | 248 +--------------------- iOverlay/src/mesh/int/math/integer.rs | 20 +- iOverlay/src/mesh/int/outline/section.rs | 4 +- iOverlay/src/mesh/int/stroke/bench.rs | 3 +- iOverlay/src/mesh/int/stroke/builder.rs | 8 +- 12 files changed, 603 insertions(+), 495 deletions(-) create mode 100644 iOverlay/src/mesh/int/arc/float.rs create mode 100644 iOverlay/src/mesh/int/arc/integer.rs create mode 100644 iOverlay/src/mesh/int/arc/tests.rs diff --git a/iOverlay/Cargo.toml b/iOverlay/Cargo.toml index d51b554e..9263c2b2 100644 --- a/iOverlay/Cargo.toml +++ b/iOverlay/Cargo.toml @@ -12,7 +12,6 @@ readme = "README.md" categories = ["algorithms", "graphics", "science::geo", "mathematics", "no-std"] [dependencies] -libm = "0.2" i_tree = { version = "^0.19.0" } i_key_sort = { version = "^0.11.0" } diff --git a/iOverlay/src/mesh/int/arc.rs b/iOverlay/src/mesh/int/arc.rs index 173e20cd..8b532013 100644 --- a/iOverlay/src/mesh/int/arc.rs +++ b/iOverlay/src/mesh/int/arc.rs @@ -1,9 +1,20 @@ -//! Directed unit-circle arcs using cached integer rotation matrices. +//! Directed unit-circle arcs with reusable storage. +//! +//! Integer and floating-point implementations share traversal and angular-gap +//! guarantees, but their error budgets and intermediate directions differ. -use alloc::vec::Vec; -use i_float::int::angle::{Angle, Rotation}; -use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; -use i_float::int::unit_vector::UnitIntVector; +mod float; +mod integer; + +#[cfg(test)] +mod tests; + +pub(crate) use float::FloatArc; +pub(crate) use integer::IntegerArc; +// Preserve the public integer builder API. +pub use integer::IntegerArc as ArcBuilder; + +use i_float::int::angle::Angle; /// Direction of traversal in Cartesian coordinates (y increases upward). #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -12,7 +23,7 @@ pub enum ArcDirection { Counterclockwise, } -/// Settings shared by all arcs built with an [`ArcBuilder`]. +/// Settings for directed arcs built with integer or floating-point arithmetic. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ArcOptions { /// Maximum angular gap, including the final gap to the original endpoint. @@ -20,7 +31,8 @@ pub struct ArcOptions { pub max_step: Angle, /// Relative matrix-angle accuracy exponent: 4 allows 1/16 error, 5 allows /// 1/32, and 6 allows 1/64. Clamped to 4..=32; 32 selects full precision. - /// This changes matrix construction cost, not the coefficient format. + /// This changes integer matrix construction cost, not the coefficient format. + /// Ignored by the floating-point implementation. pub rotation_precision: u32, } @@ -29,227 +41,34 @@ impl ArcOptions { pub const MIN_STEP: Angle = Angle::from_bits(1 << 22); /// 1/8 turn, or 45 degrees. pub const MAX_STEP: Angle = Angle::from_bits(1 << 29); -} -impl Default for ArcOptions { - fn default() -> Self { + pub(crate) fn clamped(self) -> Self { Self { - max_step: Self::MAX_STEP, - rotation_precision: 5, - } - } -} - -/// Reusable storage and two cached rotation matrices for directed arcs. -/// -/// Output contains intermediate directions in traversal order, excluding both -/// input endpoints. The caller applies the center and radius and retains the -/// original contact points to avoid rounding seams. Equal rays describe an -/// empty arc; full turns are not supported. Opposite rays describe a semicircle -/// whose side is selected by `ArcDirection`. Major arcs are supported too. -/// -/// Matrices are constructed once, with [`Rotation::with_precision`]. Each build -/// measures the sweep with [`Angle::between`] and applies the appropriate matrix -/// repeatedly, without per-point CORDIC, square roots, or normalization. Steps -/// use the achieved matrix angle; the last gap can be shorter than the others. -/// Reversing a build need not produce the same intermediate directions. -/// -/// Inputs should be freshly normalized directions: length at least 0.99 for -/// `i32`/`i64`, or 0.95 for `i16`. The step error budget includes rounding during -/// at most 1536 applications. `i16` also checks the endpoint geometrically; -/// its coarse storage can noticeably contract long fine arcs. For those arcs, -/// prefer `i32` or `i64`. Output length never exceeds the starting length. -/// -/// ``` -/// use i_float::int::vector::IntVector; -/// use i_overlay::mesh::int::arc::{ArcBuilder, ArcDirection, ArcOptions}; -/// -/// let from = IntVector::::new(1, 0).fast_normalize().unwrap(); -/// let to = IntVector::::new(0, 1).fast_normalize().unwrap(); -/// let mut builder = ArcBuilder::new(ArcOptions::default()); -/// let directions = builder.build(from, to, ArcDirection::Counterclockwise); -/// assert!(!directions.is_empty()); -/// let offset = directions[0].scale(1024); -/// assert!(offset.x > 0 && offset.y > 0); -/// ``` -pub struct ArcBuilder { - options: ArcOptions, - short_arc_dot: I::Wide, - clockwise: ArcRotation, - counterclockwise: ArcRotation, - directions: Vec>, -} - -struct ArcRotation { - matrix: Rotation, - upper_step: u64, -} - -impl ArcBuilder { - const MAX_ROTATIONS: u64 = 1536; - // i32/i64: two metadata units plus component rounding at length >= 0.99. - // i16: metadata plus component rounding, even after contraction to 0.68. - const STEP_ERROR: u64 = if I::BITS == 16 { 1 << 18 } else { 4 }; - - /// Prepares both traversal directions. The result buffer starts empty. - pub fn new(options: ArcOptions) -> Self { - let options = ArcOptions { max_step: Angle::from_bits( - options - .max_step + self.max_step .bits() - .clamp(ArcOptions::MIN_STEP.bits(), ArcOptions::MAX_STEP.bits()), + .clamp(Self::MIN_STEP.bits(), Self::MAX_STEP.bits()), ), - rotation_precision: options.rotation_precision.clamp(4, 32), - }; - // Counting by the upper step prevents endpoint overshoot. Budget the - // accumulated difference from the lower step in the final gap too. - // i16 stops geometrically, so it needs only a single-step reserve. - let reserve = if I::BITS == 16 { - 2 * Self::STEP_ERROR - } else { - 2 * Self::STEP_ERROR * (Self::MAX_ROTATIONS + 1) + 2 * Angle::MAX_ERROR as u64 - }; - let divisor = 1u64 << options.rotation_precision; - let available = options.max_step.bits() as u64 - reserve; - let requested = (available * divisor / (divisor + 1)) as u32; - let prepare = |bits| { - let matrix = Rotation::::with_precision(Angle::from_bits(bits), options.rotation_precision); - let step = (matrix.angle().bits() as i32).unsigned_abs() as u64; - debug_assert!(step > Self::STEP_ERROR); - ArcRotation { - matrix, - upper_step: step + Self::STEP_ERROR, - } - }; - // A sufficient small-arc test: input norms are <=1, so an unnormalized - // dot above cos(max_step) also bounds the normalized dot from below. - // Add eight Q30 units to cover the full CORDIC coefficient error, then - // round upward when narrowing to the Q28 dot scale of i16. - let cosine = options.max_step.cos() as u32 + 8; - let dot_bits = 2 * (I::BITS - 2); - let short_arc_dot = if dot_bits >= 30 { - I::Wide::from_u32(cosine) << (dot_bits - 30) - } else { - I::Wide::from_u32(cosine.div_ceil(1 << (30 - dot_bits))) - }; - Self { - options, - short_arc_dot, - clockwise: prepare(requested.wrapping_neg()), - counterclockwise: prepare(requested), - directions: Vec::new(), + rotation_precision: self.rotation_precision.clamp(4, 32), } } - - /// Returns the effective settings, after clamping to the supported range. - pub fn options(&self) -> ArcOptions { - self.options - } - - /// Clears the previous result, retaining its capacity, and returns the - /// intermediate directions. Settings and cached matrices remain reusable. - pub fn build( - &mut self, - from: UnitIntVector, - to: UnitIntVector, - direction: ArcDirection, - ) -> &[UnitIntVector] { - self.directions.clear(); - // Most shallow joins need no intermediate points. Avoid vectoring for - // those arcs, while distinguishing the directed minor and major arcs. - if cross(from, to, direction) >= I::Wide::ZERO && dot(from, to) >= self.short_arc_dot { - return &self.directions; - } - let (sweep, rotation) = match direction { - ArcDirection::Clockwise => (Angle::between(to, from), &self.clockwise), - ArcDirection::Counterclockwise => (Angle::between(from, to), &self.counterclockwise), - }; - if sweep.bits() == 0 - || sweep.bits() as u64 + Angle::MAX_ERROR as u64 <= self.options.max_step.bits() as u64 - { - return &self.directions; - } - - if I::BITS == 16 { - // Low precision storage accumulates too much error for a fixed - // count. Stop just before the next rotated direction reaches the - // endpoint. Each step is <90 degrees, so cross/dot suffice. - let mut current = from; - for _ in 0..Self::MAX_ROTATIONS { - let next = rotation.matrix.apply(current); - let near_end = - cross(current, to, direction) >= I::Wide::ZERO && dot(current, to) >= I::Wide::ZERO; - if cross(current, next, direction) <= I::Wide::ZERO - || (near_end && cross(next, to, direction) <= I::Wide::ZERO) - { - return &self.directions; - } - self.directions.push(next); - current = next; - } - debug_assert!(false, "freshly normalized directions must reach the arc endpoint"); - } else { - // Strictly below the lower bound on the sweep: both input endpoints - // are excluded even when the arc is an exact multiple of the step. - let lower = sweep.bits().saturating_sub(Angle::MAX_ERROR) as u64; - let count = lower.saturating_sub(1) / rotation.upper_step; - debug_assert!(count < Self::MAX_ROTATIONS); - self.directions.reserve(count as usize); - let mut current = from; - for _ in 0..count { - current = rotation.matrix.apply(current); - self.directions.push(current); - } - } - &self.directions - } } -impl Default for ArcBuilder { +impl Default for ArcOptions { fn default() -> Self { - Self::new(ArcOptions::default()) - } -} - -fn cross(a: UnitIntVector, b: UnitIntVector, direction: ArcDirection) -> I::Wide { - let cross = a.x().to_wide() * b.y().to_wide() - a.y().to_wide() * b.x().to_wide(); - match direction { - ArcDirection::Counterclockwise => cross, - ArcDirection::Clockwise => -cross, + Self { + max_step: Self::MAX_STEP, + rotation_precision: 5, + } } } -fn dot(a: UnitIntVector, b: UnitIntVector) -> I::Wide { - a.x().to_wide() * b.x().to_wide() + a.y().to_wide() * b.y().to_wide() -} - -#[cfg(test)] -mod tests { - use super::{ArcBuilder, ArcDirection, ArcOptions}; - use i_float::int::vector::IntVector; - - #[test] - fn repeated_builds_reuse_buffer_and_rotations() { - let from = IntVector::::new(1, 0).fast_normalize().unwrap(); - let to = IntVector::::new(0, -1).fast_normalize().unwrap(); - let mut builder = ArcBuilder::new(ArcOptions { - max_step: ArcOptions::MIN_STEP, - ..ArcOptions::default() - }); - builder.build(from, to, ArcDirection::Counterclockwise); - let output_ptr = builder.directions.as_ptr(); - let output_capacity = builder.directions.capacity(); - let cw = builder.clockwise.matrix.apply(from); - let ccw = builder.counterclockwise.matrix.apply(from); - assert!(output_capacity > 0); - for _ in 0..3 { - assert!(builder.build(from, from, ArcDirection::Clockwise).is_empty()); - builder.build(from, to, ArcDirection::Counterclockwise); - assert_eq!(builder.directions.as_ptr(), output_ptr); - assert_eq!(builder.directions.capacity(), output_capacity); - assert_eq!(builder.clockwise.matrix.apply(from), cw); - assert_eq!(builder.counterclockwise.matrix.apply(from), ccw); - } - } +/// Reusable directed arcs, excluding both input endpoints. +/// Equal rays produce an empty arc; opposite rays and major arcs are supported. +/// Each implementation budgets its own numerical error to keep all angular +/// gaps, including the final gap to the endpoint, within the clamped max_step. +/// Point counts and coordinates need not match between implementations. +pub(crate) trait ArcMath { + fn new(options: ArcOptions) -> Self; + fn build(&mut self, from: D, to: D, direction: ArcDirection) -> &[D]; } diff --git a/iOverlay/src/mesh/int/arc/float.rs b/iOverlay/src/mesh/int/arc/float.rs new file mode 100644 index 00000000..dde5961e --- /dev/null +++ b/iOverlay/src/mesh/int/arc/float.rs @@ -0,0 +1,206 @@ +use super::{ArcDirection, ArcMath, ArcOptions}; +use alloc::vec::Vec; +use core::f64::consts::TAU; +use i_float::float::number::FloatNumber; +#[cfg(test)] +use i_float::int::angle::Angle; +use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; +use i_float::int::unit_vector::UnitIntVector; + +#[derive(Clone, Copy, Debug)] +struct FloatUnitVector { + x: f64, + y: f64, +} + +impl FloatUnitVector { + #[cfg(test)] + fn cross(self, other: Self) -> f64 { + self.x * other.y - self.y * other.x + } + #[cfg(test)] + fn dot(self, other: Self) -> f64 { + self.x * other.x + self.y * other.y + } +} + +/// Cached f64 rotations with reusable storage. Endpoints remain the original +/// integer contact points in the caller, never reconstructed from these rays. +/// Direction lengths are approximate and may drift slightly above one. +pub(crate) struct FloatArc { + step: f64, + angle_error: f64, + sin: f64, + cos: f64, + directions: Vec>, +} + +impl FloatArc { + const MAX_ROTATIONS: usize = 1536; + // A generous angular budget for atan2 and at most 1536 matrix applications. + // The smaller cached step leaves room for this error in the final gap. + const ANGLE_ERROR: f64 = 64.0 * Self::MAX_ROTATIONS as f64 * f64::EPSILON; +} + +impl ArcMath> for FloatArc { + fn new(options: ArcOptions) -> Self { + let options = options.clamped(); + let max_step = options.max_step.bits() as f64 * (TAU / 4294967296.0); + // Component truncation adds at most about sqrt(2)/S angular error + // per output ray; reserve 2/S for each end of a gap. + let angle_error = Self::ANGLE_ERROR + 2.0 / UnitIntVector::::DENOMINATOR.to_f64(); + let step = max_step - 2.0 * angle_error; + let (sin, cos) = FloatNumber::sin_cos(step); + Self { + step, + angle_error, + sin, + cos, + directions: Vec::new(), + } + } + + fn build( + &mut self, + from: UnitIntVector, + to: UnitIntVector, + direction: ArcDirection, + ) -> &[UnitIntVector] { + self.directions.clear(); + let a = crate::mesh::int::math::vector(from); + let b = crate::mesh::int::math::vector(to); + let cross = a.cross_product(b).to_f64(); + let dot = a.dot_product(b).to_f64(); + let scale = UnitIntVector::::DENOMINATOR.to_f64(); + let from = float_vector(from); + let sign = match direction { + ArcDirection::Clockwise => -1.0, + ArcDirection::Counterclockwise => 1.0, + }; + let cross = sign * cross; + // Conservatively skip short arcs without atan2. Account for unit-vector + // normalization error when comparing the dot product with cos(step). + if cross >= 0.0 && dot / (scale * scale) >= self.cos + 8.0 * f64::EPSILON { + return &self.directions; + } + let mut sweep = FloatNumber::atan2(cross, dot); + if sweep < 0.0 { + sweep += TAU; + } + // The quotient is nonnegative, so truncation is equivalent to floor. + let count = ((sweep - self.angle_error).max(0.0) / self.step) as usize; + debug_assert!(count <= Self::MAX_ROTATIONS); + self.directions.reserve(count); + let sin = sign * self.sin; + let mut current = from; + for _ in 0..count { + current = FloatUnitVector { + x: self.cos * current.x - sin * current.y, + y: sin * current.x + self.cos * current.y, + }; + // Keep f64 rotation state between steps; quantization does not + // accumulate. Small length drift from float rounding is accepted. + self.directions + .push(UnitIntVector::from_float_unchecked(current.x, current.y)); + } + &self.directions + } +} + +fn float_vector(direction: UnitIntVector) -> FloatUnitVector { + let scale = UnitIntVector::::DENOMINATOR.to_f64(); + FloatUnitVector { + x: direction.x().to_wide().to_f64() / scale, + y: direction.y().to_wide().to_f64() / scale, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn check_arcs() { + let scale = UnitIntVector::::DENOMINATOR; + let unit = |angle: f64| { + let (sin, cos) = FloatNumber::sin_cos(angle); + UnitIntVector::::from_float_unchecked(cos, sin) + }; + let sweep = |a: UnitIntVector, b: UnitIntVector, sign: f64| { + let a = float_vector(a); + let b = float_vector(b); + let angle = FloatNumber::atan2(sign * a.cross(b), a.dot(b)); + if angle < 0.0 { angle + TAU } else { angle } + }; + for bits in [ArcOptions::MIN_STEP.bits(), 1 << 26, ArcOptions::MAX_STEP.bits()] { + let max_step = bits as f64 * TAU / 4294967296.0; + let mut arc = FloatArc::::new(ArcOptions { + max_step: Angle::from_bits(bits), + rotation_precision: 5, + }); + for start in [0.0, 0.3, 1.7, 3.1, 4.9] { + for extent in [ + 0.0, + 1e-8, + max_step, + max_step + 1e-6, + 1.0, + core::f64::consts::PI, + TAU - 1e-8, + ] { + for direction in [ArcDirection::Clockwise, ArcDirection::Counterclockwise] { + let sign = if direction == ArcDirection::Clockwise { + -1.0 + } else { + 1.0 + }; + let from = unit(start); + let to = unit(start + sign * extent); + let total = sweep(from, to, sign); + let mut previous = 0.0; + for &point in arc.build(from, to, direction) { + let x = point.x().to_f64() / scale.to_f64(); + let y = point.y().to_f64() / scale.to_f64(); + let norm = FloatNumber::sqrt(x * x + y * y); + let tolerance = FloatArc::::ANGLE_ERROR + 4.0 / scale.to_f64(); + assert!((norm - 1.0).abs() <= tolerance); + let progress = sweep(from, point, sign); + assert!( + progress > previous && progress < total, + "bits={}, progress={progress}, previous={previous}, total={total}", + I::BITS + ); + assert!(progress - previous <= max_step + 1e-12); + previous = progress; + } + assert!(total - previous <= max_step + 1e-12); + } + } + } + arc.build(unit(0.0), unit(3.0), ArcDirection::Clockwise); + let capacity = arc.directions.capacity(); + let ptr = arc.directions.as_ptr(); + for _ in 0..3 { + assert!( + arc.build(unit(0.0), unit(0.0), ArcDirection::Clockwise) + .is_empty() + ); + arc.build(unit(0.0), unit(3.0), ArcDirection::Clockwise); + assert_eq!(arc.directions.capacity(), capacity); + assert_eq!(arc.directions.as_ptr(), ptr); + } + } + } + + #[test] + fn arc_bounds_and_reuse_i16() { + check_arcs::(); + } + #[test] + fn arc_bounds_and_reuse_i32() { + check_arcs::(); + } + #[test] + fn arc_bounds_and_reuse_i64() { + check_arcs::(); + } +} diff --git a/iOverlay/src/mesh/int/arc/integer.rs b/iOverlay/src/mesh/int/arc/integer.rs new file mode 100644 index 00000000..4c5319f7 --- /dev/null +++ b/iOverlay/src/mesh/int/arc/integer.rs @@ -0,0 +1,225 @@ +use super::{ArcDirection, ArcMath, ArcOptions}; +use alloc::vec::Vec; +use i_float::int::angle::{Angle, Rotation}; +use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; +use i_float::int::unit_vector::UnitIntVector; + +/// Reusable storage and two cached rotation matrices for directed arcs. +/// +/// Output contains intermediate directions in traversal order, excluding both +/// input endpoints. The caller applies the center and radius and retains the +/// original contact points to avoid rounding seams. Equal rays describe an +/// empty arc; full turns are not supported. Opposite rays describe a semicircle +/// whose side is selected by `ArcDirection`. Major arcs are supported too. +/// +/// Matrices are constructed once, with [`Rotation::with_precision`]. Each build +/// measures the sweep with [`Angle::between`] and applies the appropriate matrix +/// repeatedly, without per-point CORDIC, square roots, or normalization. Steps +/// use the achieved matrix angle; the last gap can be shorter than the others. +/// Reversing a build need not produce the same intermediate directions. +/// +/// Inputs should be freshly normalized directions: length at least 0.99 for +/// `i32`/`i64`, or 0.95 for `i16`. The step error budget includes rounding during +/// at most 1536 applications. `i16` also checks the endpoint geometrically; +/// its coarse storage can noticeably contract long fine arcs. For those arcs, +/// prefer `i32` or `i64`. Output length never exceeds the starting length. +/// +/// ``` +/// use i_float::int::vector::IntVector; +/// use i_overlay::mesh::int::arc::{ArcBuilder, ArcDirection, ArcOptions}; +/// +/// let from = IntVector::::new(1, 0).fast_normalize().unwrap(); +/// let to = IntVector::::new(0, 1).fast_normalize().unwrap(); +/// let mut builder = ArcBuilder::new(ArcOptions::default()); +/// let directions = builder.build(from, to, ArcDirection::Counterclockwise); +/// assert!(!directions.is_empty()); +/// let offset = directions[0].scale(1024); +/// assert!(offset.x > 0 && offset.y > 0); +/// ``` +pub struct IntegerArc { + options: ArcOptions, + short_arc_dot: I::Wide, + clockwise: ArcRotation, + counterclockwise: ArcRotation, + directions: Vec>, +} + +struct ArcRotation { + matrix: Rotation, + upper_step: u64, +} + +impl IntegerArc { + const MAX_ROTATIONS: u64 = 1536; + // i32/i64: two metadata units plus component rounding at length >= 0.99. + // i16: metadata plus component rounding, even after contraction to 0.68. + const STEP_ERROR: u64 = if I::BITS == 16 { 1 << 18 } else { 4 }; + + /// Prepares both traversal directions. The result buffer starts empty. + pub fn new(options: ArcOptions) -> Self { + let options = options.clamped(); + // Counting by the upper step prevents endpoint overshoot. Budget the + // accumulated difference from the lower step in the final gap too. + // i16 stops geometrically, so it needs only a single-step reserve. + let reserve = if I::BITS == 16 { + 2 * Self::STEP_ERROR + } else { + 2 * Self::STEP_ERROR * (Self::MAX_ROTATIONS + 1) + 2 * Angle::MAX_ERROR as u64 + }; + let divisor = 1u64 << options.rotation_precision; + let available = options.max_step.bits() as u64 - reserve; + let requested = (available * divisor / (divisor + 1)) as u32; + let prepare = |bits| { + let matrix = Rotation::::with_precision(Angle::from_bits(bits), options.rotation_precision); + let step = (matrix.angle().bits() as i32).unsigned_abs() as u64; + debug_assert!(step > Self::STEP_ERROR); + ArcRotation { + matrix, + upper_step: step + Self::STEP_ERROR, + } + }; + // A sufficient small-arc test: input norms are <=1, so an unnormalized + // dot above cos(max_step) also bounds the normalized dot from below. + // Add eight Q30 units to cover the full CORDIC coefficient error, then + // round upward when narrowing to the Q28 dot scale of i16. + let cosine = options.max_step.cos() as u32 + 8; + let dot_bits = 2 * (I::BITS - 2); + let short_arc_dot = if dot_bits >= 30 { + I::Wide::from_u32(cosine) << (dot_bits - 30) + } else { + I::Wide::from_u32(cosine.div_ceil(1 << (30 - dot_bits))) + }; + Self { + options, + short_arc_dot, + clockwise: prepare(requested.wrapping_neg()), + counterclockwise: prepare(requested), + directions: Vec::new(), + } + } + + /// Returns the effective settings, after clamping to the supported range. + pub fn options(&self) -> ArcOptions { + self.options + } + + /// Clears the previous result, retaining its capacity, and returns the + /// intermediate directions. Settings and cached matrices remain reusable. + pub fn build( + &mut self, + from: UnitIntVector, + to: UnitIntVector, + direction: ArcDirection, + ) -> &[UnitIntVector] { + self.directions.clear(); + // Most shallow joins need no intermediate points. Avoid vectoring for + // those arcs, while distinguishing the directed minor and major arcs. + if cross(from, to, direction) >= I::Wide::ZERO && dot(from, to) >= self.short_arc_dot { + return &self.directions; + } + let (sweep, rotation) = match direction { + ArcDirection::Clockwise => (Angle::between(to, from), &self.clockwise), + ArcDirection::Counterclockwise => (Angle::between(from, to), &self.counterclockwise), + }; + if sweep.bits() == 0 + || sweep.bits() as u64 + Angle::MAX_ERROR as u64 <= self.options.max_step.bits() as u64 + { + return &self.directions; + } + + if I::BITS == 16 { + // Low precision storage accumulates too much error for a fixed + // count. Stop just before the next rotated direction reaches the + // endpoint. Each step is <90 degrees, so cross/dot suffice. + let mut current = from; + for _ in 0..Self::MAX_ROTATIONS { + let next = rotation.matrix.apply(current); + let near_end = + cross(current, to, direction) >= I::Wide::ZERO && dot(current, to) >= I::Wide::ZERO; + if cross(current, next, direction) <= I::Wide::ZERO + || (near_end && cross(next, to, direction) <= I::Wide::ZERO) + { + return &self.directions; + } + self.directions.push(next); + current = next; + } + debug_assert!(false, "freshly normalized directions must reach the arc endpoint"); + } else { + // Strictly below the lower bound on the sweep: both input endpoints + // are excluded even when the arc is an exact multiple of the step. + let lower = sweep.bits().saturating_sub(Angle::MAX_ERROR) as u64; + let count = lower.saturating_sub(1) / rotation.upper_step; + debug_assert!(count < Self::MAX_ROTATIONS); + self.directions.reserve(count as usize); + let mut current = from; + for _ in 0..count { + current = rotation.matrix.apply(current); + self.directions.push(current); + } + } + &self.directions + } +} + +impl Default for IntegerArc { + fn default() -> Self { + Self::new(ArcOptions::default()) + } +} + +impl ArcMath> for IntegerArc { + fn new(options: ArcOptions) -> Self { + IntegerArc::new(options) + } + fn build( + &mut self, + from: UnitIntVector, + to: UnitIntVector, + direction: ArcDirection, + ) -> &[UnitIntVector] { + IntegerArc::build(self, from, to, direction) + } +} + +fn cross(a: UnitIntVector, b: UnitIntVector, direction: ArcDirection) -> I::Wide { + let cross = a.x().to_wide() * b.y().to_wide() - a.y().to_wide() * b.x().to_wide(); + match direction { + ArcDirection::Counterclockwise => cross, + ArcDirection::Clockwise => -cross, + } +} + +fn dot(a: UnitIntVector, b: UnitIntVector) -> I::Wide { + a.x().to_wide() * b.x().to_wide() + a.y().to_wide() * b.y().to_wide() +} + +#[cfg(test)] +mod tests { + use super::{ArcDirection, ArcOptions, IntegerArc}; + use i_float::int::vector::IntVector; + + #[test] + fn repeated_builds_reuse_buffer_and_rotations() { + let from = IntVector::::new(1, 0).fast_normalize().unwrap(); + let to = IntVector::::new(0, -1).fast_normalize().unwrap(); + let mut builder = IntegerArc::new(ArcOptions { + max_step: ArcOptions::MIN_STEP, + ..ArcOptions::default() + }); + builder.build(from, to, ArcDirection::Counterclockwise); + let output_ptr = builder.directions.as_ptr(); + let output_capacity = builder.directions.capacity(); + let cw = builder.clockwise.matrix.apply(from); + let ccw = builder.counterclockwise.matrix.apply(from); + assert!(output_capacity > 0); + for _ in 0..3 { + assert!(builder.build(from, from, ArcDirection::Clockwise).is_empty()); + builder.build(from, to, ArcDirection::Counterclockwise); + assert_eq!(builder.directions.as_ptr(), output_ptr); + assert_eq!(builder.directions.capacity(), output_capacity); + assert_eq!(builder.clockwise.matrix.apply(from), cw); + assert_eq!(builder.counterclockwise.matrix.apply(from), ccw); + } + } +} diff --git a/iOverlay/src/mesh/int/arc/tests.rs b/iOverlay/src/mesh/int/arc/tests.rs new file mode 100644 index 00000000..e10cdcb2 --- /dev/null +++ b/iOverlay/src/mesh/int/arc/tests.rs @@ -0,0 +1,119 @@ +use super::{ArcDirection, ArcMath, ArcOptions, FloatArc, IntegerArc}; +use alloc::vec::Vec; +use core::f64::consts::TAU; +use i_float::float::number::FloatNumber; +use i_float::int::angle::Angle; +use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; +use i_float::int::{unit_vector::UnitIntVector, vector::IntVector}; + +fn unit(x: i32, y: i32) -> UnitIntVector { + let coordinate = |value: i32| { + let magnitude = I::Wide::from_u32(value.unsigned_abs()); + if value < 0 { -magnitude } else { magnitude } + }; + IntVector::::new(coordinate(x), coordinate(y)) + .fast_normalize() + .unwrap() +} + +fn angle(ray: UnitIntVector) -> f64 { + FloatNumber::atan2(ray.y().to_f64(), ray.x().to_f64()) +} + +// Both implementations must satisfy the same traversal and gap guarantees. +// Their point counts, coordinates, and length error are intentionally not compared. +fn check_contract>>() { + let rays: Vec<_> = [ + (1, 0), + (10000, 1), + (3, 4), + (1, 1), + (0, 1), + (-1, 10000), + (-4, 3), + (-1, 0), + (-10000, -1), + (-3, -4), + (0, -1), + (10000, -1), + ] + .into_iter() + .map(|(x, y)| unit::(x, y)) + .collect(); + + for max_step in [0, 1 << 26, u32::MAX] { + for rotation_precision in [0, 5, u32::MAX] { + let options = ArcOptions { + max_step: Angle::from_bits(max_step), + rotation_precision, + }; + let max_gap = options.clamped().max_step.bits() as f64 * TAU / 4294967296.0; + let mut arc = A::new(options); + for &from in &rays { + for &to in &rays { + for direction in [ArcDirection::Clockwise, ArcDirection::Counterclockwise] { + let sign = match direction { + ArcDirection::Clockwise => -1.0, + ArcDirection::Counterclockwise => 1.0, + }; + let progress = |ray| { + let delta = sign * (angle(ray) - angle(from)); + if delta < 0.0 { delta + TAU } else { delta } + }; + let total = progress(to); + let output = arc.build(from, to, direction); + if total == 0.0 { + assert!(output.is_empty()); + continue; + } + let mut previous = 0.0; + for &ray in output { + let current = progress(ray); + assert!(current > previous && current < total); + assert!(current - previous <= max_gap + 1e-12); + previous = current; + } + assert!(total - previous <= max_gap + 1e-12); + } + } + } + + let from = unit::(1, 0); + let direction = ArcDirection::Counterclockwise; + assert!(!arc.build(from, unit(-1, 0), direction).is_empty()); + assert!(arc.build(from, from, direction).is_empty()); + assert!(!arc.build(from, unit(-1, 0), direction).is_empty()); + assert!(arc.build(from, unit(10000, 1), direction).is_empty()); + } + } +} + +#[test] +fn integer_contract_i16() { + check_contract::>(); +} + +#[test] +fn integer_contract_i32() { + check_contract::>(); +} + +#[test] +fn integer_contract_i64() { + check_contract::>(); +} + +#[test] +fn float_contract_i16() { + check_contract::>(); +} + +#[test] +fn float_contract_i32() { + check_contract::>(); +} + +#[test] +fn float_contract_i64() { + check_contract::>(); +} diff --git a/iOverlay/src/mesh/int/join.rs b/iOverlay/src/mesh/int/join.rs index 5968084f..38511656 100644 --- a/iOverlay/src/mesh/int/join.rs +++ b/iOverlay/src/mesh/int/join.rs @@ -1,5 +1,5 @@ -use super::arc::ArcDirection; -use super::math::backend::{ArcMath, MeshMath}; +use super::arc::{ArcDirection, ArcMath}; +use super::math::backend::MeshMath; use super::math::integer::IntegerMath; use super::math::{abs, mul_div, point, scaled_point, vector}; use super::style::IntLineJoin; diff --git a/iOverlay/src/mesh/int/math/backend.rs b/iOverlay/src/mesh/int/math/backend.rs index b9ba8db7..03a624d4 100644 --- a/iOverlay/src/mesh/int/math/backend.rs +++ b/iOverlay/src/mesh/int/math/backend.rs @@ -1,4 +1,4 @@ -use crate::mesh::int::arc::{ArcDirection, ArcOptions}; +use crate::mesh::int::arc::ArcMath; use i_float::int::{ angle::Angle, number::int::IntNumber, point::IntPoint, unit_vector::UnitIntVector, vector::IntVector, }; @@ -14,8 +14,3 @@ pub(crate) trait MeshMath: Copy { fn rotate(direction: UnitIntVector, local: IntPoint) -> IntVector; fn guard_padding(padding: I::Wide) -> I::Wide; } - -pub(crate) trait ArcMath { - fn new(options: ArcOptions) -> Self; - fn build(&mut self, from: D, to: D, direction: ArcDirection) -> &[D]; -} diff --git a/iOverlay/src/mesh/int/math/float.rs b/iOverlay/src/mesh/int/math/float.rs index f4a28c3b..620fb619 100644 --- a/iOverlay/src/mesh/int/math/float.rs +++ b/iOverlay/src/mesh/int/math/float.rs @@ -1,75 +1,25 @@ -use super::backend::{ArcMath, MeshMath}; +use super::backend::MeshMath; use super::integer::IntegerMath; - -use crate::mesh::int::arc::{ArcDirection, ArcOptions}; -use alloc::vec::Vec; -use core::f64::consts::TAU; +use crate::mesh::int::arc::FloatArc; use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; use i_float::int::{angle::Angle, point::IntPoint, unit_vector::UnitIntVector, vector::IntVector}; #[derive(Clone, Copy)] pub(crate) struct FloatMath; -#[derive(Clone, Copy, Debug)] -pub(crate) struct FloatUnitVector { - pub(super) x: f64, - pub(super) y: f64, -} - -impl FloatUnitVector { - #[cfg(test)] - fn cross(self, other: Self) -> f64 { - self.x * other.y - self.y * other.x - } - #[cfg(test)] - fn dot(self, other: Self) -> f64 { - self.x * other.x + self.y * other.y - } -} - impl MeshMath for FloatMath { type Arc = FloatArc; fn sin_cos(angle: Angle) -> (i32, i32) { - let (sin, cos) = libm::sincos(angle.bits() as f64 * (TAU / 4294967296.0)); - ( - libm::round(sin * (1_u32 << 30) as f64) as i32, - libm::round(cos * (1_u32 << 30) as f64) as i32, - ) + angle.sin_cos_with_float() } fn angle_between(from: UnitIntVector, to: UnitIntVector) -> Angle { - // Form cross/dot exactly before conversion, preserving the side of a - // near-parallel turn even when f64 products would cancel to zero. - let a = super::vector(from); - let b = super::vector(to); - let cross = a.cross_product(b); - let dot = a.dot_product(b); - let angle = Angle::from_radians(libm::atan2(cross.to_f64(), dot.to_f64())); - if cross != I::Wide::ZERO && angle.bits() == 0 { - Angle::from_bits(if cross > I::Wide::ZERO { 1 } else { u32::MAX }) - } else { - angle - } + Angle::between_with_float(from, to) } #[inline] fn normalize(v: IntVector) -> Option> { - if v.x == I::Wide::ZERO && v.y == I::Wide::ZERO { - return None; - } - // The caller subtracts integer origins before conversion. Squaring even - // an i128 component fits f64, so no rescaling is needed here. - let x = v.x.to_f64(); - let y = v.y.to_f64(); - if x == 0.0 { - return UnitIntVector::try_from_float(0.0, y.signum()); - } - if y == 0.0 { - return UnitIntVector::try_from_float(x.signum(), 0.0); - } - let length = libm::sqrt(x * x + y * y); - let inward = 1.0 - 4.0 * f64::EPSILON; - UnitIntVector::try_from_float(x / length * inward, y / length * inward) + UnitIntVector::normalize_with_float(v) } #[inline] @@ -94,191 +44,3 @@ impl MeshMath for FloatMath { padding + relative + I::Wide::FOUR } } - -/// Cached f64 rotations with reusable storage. Endpoints remain the original -/// integer contact points in the caller, never reconstructed from these rays. -pub(crate) struct FloatArc { - step: f64, - angle_error: f64, - sin: f64, - cos: f64, - directions: Vec>, -} - -impl FloatArc { - const MAX_ROTATIONS: usize = 1536; - // A generous angular budget for atan2 and at most 1536 matrix applications. - // The smaller cached step leaves room for this error in the final gap. - const ANGLE_ERROR: f64 = 64.0 * Self::MAX_ROTATIONS as f64 * f64::EPSILON; -} - -impl ArcMath> for FloatArc { - fn new(options: ArcOptions) -> Self { - let bits = options - .max_step - .bits() - .clamp(ArcOptions::MIN_STEP.bits(), ArcOptions::MAX_STEP.bits()); - let max_step = bits as f64 * (TAU / 4294967296.0); - // Component truncation adds at most about sqrt(2)/S angular error - // per output ray; reserve 2/S for each end of a gap. - let angle_error = Self::ANGLE_ERROR + 2.0 / UnitIntVector::::DENOMINATOR.to_f64(); - let step = max_step - 2.0 * angle_error; - let (sin, cos) = libm::sincos(step); - Self { - step, - angle_error, - sin, - cos, - directions: Vec::new(), - } - } - - fn build( - &mut self, - from: UnitIntVector, - to: UnitIntVector, - direction: ArcDirection, - ) -> &[UnitIntVector] { - self.directions.clear(); - let a = super::vector(from); - let b = super::vector(to); - let cross = a.cross_product(b).to_f64(); - let dot = a.dot_product(b).to_f64(); - let scale = UnitIntVector::::DENOMINATOR.to_f64(); - let from = float_vector(from); - let sign = match direction { - ArcDirection::Clockwise => -1.0, - ArcDirection::Counterclockwise => 1.0, - }; - let cross = sign * cross; - // Conservatively skip short arcs without atan2. Account for unit-vector - // normalization error when comparing the dot product with cos(step). - if cross >= 0.0 && dot / (scale * scale) >= self.cos + 8.0 * f64::EPSILON { - return &self.directions; - } - let mut sweep = libm::atan2(cross, dot); - if sweep < 0.0 { - sweep += TAU; - } - let count = libm::floor((sweep - self.angle_error).max(0.0) / self.step) as usize; - debug_assert!(count <= Self::MAX_ROTATIONS); - self.directions.reserve(count); - let sin = sign * self.sin; - let mut current = from; - for _ in 0..count { - current = FloatUnitVector { - x: self.cos * current.x - sin * current.y, - y: sin * current.x + self.cos * current.y, - }; - // Keep f64 rotation state between steps; quantization does not - // accumulate. Contract slightly to cover rotation roundoff before - // the constructor's exact integer length check. - let inward = 1.0 - Self::ANGLE_ERROR; - self.directions.push( - UnitIntVector::try_from_float(current.x * inward, current.y * inward) - .expect("contracted rotation stays inside the unit disk"), - ); - } - &self.directions - } -} - -fn float_vector(direction: UnitIntVector) -> FloatUnitVector { - let scale = UnitIntVector::::DENOMINATOR.to_f64(); - FloatUnitVector { - x: direction.x().to_wide().to_f64() / scale, - y: direction.y().to_wide().to_f64() / scale, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn check_arcs() { - let scale = UnitIntVector::::DENOMINATOR; - let unit = |angle: f64| { - let (sin, cos) = libm::sincos(angle); - UnitIntVector::::try_from_float( - cos * (1.0 - 8.0 * f64::EPSILON), - sin * (1.0 - 8.0 * f64::EPSILON), - ) - .unwrap() - }; - let sweep = |a: UnitIntVector, b: UnitIntVector, sign: f64| { - let a = float_vector(a); - let b = float_vector(b); - let angle = libm::atan2(sign * a.cross(b), a.dot(b)); - if angle < 0.0 { angle + TAU } else { angle } - }; - for bits in [ArcOptions::MIN_STEP.bits(), 1 << 26, ArcOptions::MAX_STEP.bits()] { - let max_step = bits as f64 * TAU / 4294967296.0; - let mut arc = FloatArc::::new(ArcOptions { - max_step: Angle::from_bits(bits), - rotation_precision: 5, - }); - for start in [0.0, 0.3, 1.7, 3.1, 4.9] { - for extent in [ - 0.0, - 1e-8, - max_step, - max_step + 1e-6, - 1.0, - core::f64::consts::PI, - TAU - 1e-8, - ] { - for direction in [ArcDirection::Clockwise, ArcDirection::Counterclockwise] { - let sign = if direction == ArcDirection::Clockwise { - -1.0 - } else { - 1.0 - }; - let from = unit(start); - let to = unit(start + sign * extent); - let total = sweep(from, to, sign); - let mut previous = 0.0; - for &point in arc.build(from, to, direction) { - let x = point.x().to_wide(); - let y = point.y().to_wide(); - assert!(x * x + y * y <= scale * scale); - let progress = sweep(from, point, sign); - assert!( - progress > previous && progress < total, - "bits={}, progress={progress}, previous={previous}, total={total}", - I::BITS - ); - assert!(progress - previous <= max_step + 1e-12); - previous = progress; - } - assert!(total - previous <= max_step + 1e-12); - } - } - } - arc.build(unit(0.0), unit(3.0), ArcDirection::Clockwise); - let capacity = arc.directions.capacity(); - let ptr = arc.directions.as_ptr(); - for _ in 0..3 { - assert!( - arc.build(unit(0.0), unit(0.0), ArcDirection::Clockwise) - .is_empty() - ); - arc.build(unit(0.0), unit(3.0), ArcDirection::Clockwise); - assert_eq!(arc.directions.capacity(), capacity); - assert_eq!(arc.directions.as_ptr(), ptr); - } - } - } - - #[test] - fn arc_bounds_and_reuse_i16() { - check_arcs::(); - } - #[test] - fn arc_bounds_and_reuse_i32() { - check_arcs::(); - } - #[test] - fn arc_bounds_and_reuse_i64() { - check_arcs::(); - } -} diff --git a/iOverlay/src/mesh/int/math/integer.rs b/iOverlay/src/mesh/int/math/integer.rs index 72153bbe..df7ab3dc 100644 --- a/iOverlay/src/mesh/int/math/integer.rs +++ b/iOverlay/src/mesh/int/math/integer.rs @@ -1,6 +1,6 @@ -use super::backend::{ArcMath, MeshMath}; +use super::backend::MeshMath; use super::{direction, vector}; -use crate::mesh::int::arc::{ArcBuilder, ArcDirection, ArcOptions}; +use crate::mesh::int::arc::IntegerArc; use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; use i_float::int::{angle::Angle, point::IntPoint, unit_vector::UnitIntVector, vector::IntVector}; @@ -8,7 +8,7 @@ use i_float::int::{angle::Angle, point::IntPoint, unit_vector::UnitIntVector, ve pub(crate) struct IntegerMath; impl MeshMath for IntegerMath { - type Arc = ArcBuilder; + type Arc = IntegerArc; #[inline] fn sin_cos(angle: Angle) -> (i32, i32) { angle.sin_cos() @@ -43,17 +43,3 @@ impl MeshMath for IntegerMath { padding } } - -impl ArcMath> for ArcBuilder { - fn new(options: ArcOptions) -> Self { - ArcBuilder::new(options) - } - fn build( - &mut self, - from: UnitIntVector, - to: UnitIntVector, - direction: ArcDirection, - ) -> &[UnitIntVector] { - ArcBuilder::build(self, from, to, direction) - } -} diff --git a/iOverlay/src/mesh/int/outline/section.rs b/iOverlay/src/mesh/int/outline/section.rs index 0fede1de..ba4b7648 100644 --- a/iOverlay/src/mesh/int/outline/section.rs +++ b/iOverlay/src/mesh/int/outline/section.rs @@ -1,4 +1,4 @@ -use crate::mesh::int::math::point; +use crate::mesh::int::math::{direction, point}; use crate::mesh::uniq_iter::UniqueSegment; use i_float::int::number::int::IntNumber; use i_float::int::point::IntPoint; @@ -17,7 +17,7 @@ pub(super) struct OffsetSection { impl OffsetSection { pub(super) fn new(segment: UniqueSegment, offset: I) -> Self { let (a, b) = (segment.a, segment.b); - let direction = crate::mesh::int::math::direction(b - a).expect("unique segment"); + let direction = direction(b - a).expect("unique segment"); if offset == I::ZERO { return Self { offset, diff --git a/iOverlay/src/mesh/int/stroke/bench.rs b/iOverlay/src/mesh/int/stroke/bench.rs index 169f865c..bab05b01 100644 --- a/iOverlay/src/mesh/int/stroke/bench.rs +++ b/iOverlay/src/mesh/int/stroke/bench.rs @@ -10,6 +10,7 @@ use crate::mesh::int::{ use crate::mesh::math::MathMode; use alloc::vec::Vec; use core::hint::black_box; +use i_float::float::number::FloatNumber; use i_float::int::{angle::Angle, point::IntPoint}; use std::{println, time::Instant}; @@ -38,7 +39,7 @@ fn benchmark() { + if jagged { if j % 2 == 0 { 0.0 } else { 4096.0 } } else { - libm::sin(j as f64 * 0.18) * 4096.0 + FloatNumber::sin(j as f64 * 0.18) * 4096.0 }; IntPoint::new(I::from_rounded_float(x), I::from_rounded_float(y)) }) diff --git a/iOverlay/src/mesh/int/stroke/builder.rs b/iOverlay/src/mesh/int/stroke/builder.rs index 27b82075..eb68c917 100644 --- a/iOverlay/src/mesh/int/stroke/builder.rs +++ b/iOverlay/src/mesh/int/stroke/builder.rs @@ -1,11 +1,7 @@ use crate::mesh::int::{ - arc::ArcDirection, + arc::{ArcDirection, ArcMath}, join::Join, - math::{ - backend::{ArcMath, MeshMath}, - integer::IntegerMath, - point, - }, + math::{backend::MeshMath, integer::IntegerMath, point}, style::{IntLineCap, IntStrokeStyle}, }; use crate::mesh::subject::SubjectSegments; From 3cc67b76168140258947147bb31a191f98cb08dc Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Thu, 17 Sep 2026 10:01:55 +0300 Subject: [PATCH 27/44] regroup --- iOverlay/src/mesh/float/stroke/offset.rs | 2 +- iOverlay/src/mesh/int/join.rs | 4 +- iOverlay/src/mesh/int/outline/mod.rs | 3 +- iOverlay/src/mesh/int/stroke/bounds.rs | 63 +++++++ iOverlay/src/mesh/int/stroke/build.rs | 153 ++++++++++++++++ iOverlay/src/mesh/int/stroke/builder.rs | 173 ++---------------- iOverlay/src/mesh/int/stroke/builder_join.rs | 53 ++++++ iOverlay/src/mesh/int/stroke/cap.rs | 70 +++++++ iOverlay/src/mesh/int/stroke/mod.rs | 8 +- iOverlay/src/mesh/int/stroke/offset.rs | 161 +--------------- iOverlay/src/mesh/int/stroke/section.rs | 37 ++++ .../stroke/bench.rs => tests/stroke_bench.rs} | 62 +++---- 12 files changed, 427 insertions(+), 362 deletions(-) create mode 100644 iOverlay/src/mesh/int/stroke/bounds.rs create mode 100644 iOverlay/src/mesh/int/stroke/build.rs create mode 100644 iOverlay/src/mesh/int/stroke/builder_join.rs create mode 100644 iOverlay/src/mesh/int/stroke/cap.rs create mode 100644 iOverlay/src/mesh/int/stroke/section.rs rename iOverlay/{src/mesh/int/stroke/bench.rs => tests/stroke_bench.rs} (60%) diff --git a/iOverlay/src/mesh/float/stroke/offset.rs b/iOverlay/src/mesh/float/stroke/offset.rs index 7525ac14..ac042c1c 100644 --- a/iOverlay/src/mesh/float/stroke/offset.rs +++ b/iOverlay/src/mesh/float/stroke/offset.rs @@ -4,7 +4,7 @@ use crate::core::overlay_rule::OverlayRule; use crate::float::overlay::OverlayOptions; use crate::float::scale::FixedScaleOverlayError; use crate::mesh::float::style::StrokeStyle; -use crate::mesh::int::stroke::offset::build_stroke_overlay_iter; +use crate::mesh::int::stroke::build_stroke_overlay_iter; use alloc::vec; use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; diff --git a/iOverlay/src/mesh/int/join.rs b/iOverlay/src/mesh/int/join.rs index 38511656..cd6cbf9d 100644 --- a/iOverlay/src/mesh/int/join.rs +++ b/iOverlay/src/mesh/int/join.rs @@ -22,9 +22,11 @@ impl> Join { IntLineJoin::Bevel => Self::Bevel, IntLineJoin::Round(options) => Self::Round(M::Arc::new(options)), IntLineJoin::Miter(angle) => { + let min_angle = (1u32 << 31) / 100; + let max_angle = (1u32 << 31) - 1; let minimum = angle .bits() - .clamp((1u32 << 31) / 100, ((1u64 << 31) * 99 / 100) as u32); + .clamp(min_angle, max_angle); let (sin, cos) = M::sin_cos(Angle::from_bits(minimum / 2)); Self::Miter { minimum, sin, cos } } diff --git a/iOverlay/src/mesh/int/outline/mod.rs b/iOverlay/src/mesh/int/outline/mod.rs index 31f14d8a..2e32f2de 100644 --- a/iOverlay/src/mesh/int/outline/mod.rs +++ b/iOverlay/src/mesh/int/outline/mod.rs @@ -1,7 +1,8 @@ mod bounds; mod build; -pub(crate) use build::BuildOutlineOverlay; mod builder; mod builder_join; pub mod offset; mod section; + +pub(crate) use build::BuildOutlineOverlay; diff --git a/iOverlay/src/mesh/int/stroke/bounds.rs b/iOverlay/src/mesh/int/stroke/bounds.rs new file mode 100644 index 00000000..6ed28731 --- /dev/null +++ b/iOverlay/src/mesh/int/stroke/bounds.rs @@ -0,0 +1,63 @@ +use super::offset::IntStrokeError; +use crate::core::integer::OverlayInt; +use crate::mesh::int::{ + join::Join, + math::{backend::MeshMath, float::FloatMath, integer::IntegerMath}, + style::{IntLineCap, IntStrokeStyle}, +}; +use crate::mesh::math::MathMode; +use i_float::int::{ + number::{int::IntNumber, uint::UIntNumber, wide_int::WideIntNumber}, + rect::IntRect, +}; +use i_shape::source::int::resource::IntShapeResource; + +pub(super) trait StrokeBounds: IntShapeResource { + fn validate_stroke_bounds(&self, style: &IntStrokeStyle) -> Result<(), IntStrokeError> { + if let Some(rect) = IntRect::with_iter(self.iter_paths().flatten()) { + if !crate::mesh::int::bounds::expanded_is_safe(rect, stroke_padding(style)) { + return Err(IntStrokeError::CoordinateOutOfRange); + } + } + Ok(()) + } +} + +impl + ?Sized> StrokeBounds for S {} + +fn stroke_padding(style: &IntStrokeStyle) -> I::Wide { + match style.math { + MathMode::Integer => stroke_padding_with_math::(style), + MathMode::Float => stroke_padding_with_math::(style), + } +} + +pub(super) fn stroke_radius(style: &IntStrokeStyle) -> I { + I::from_wide((style.width.max(I::ZERO).to_wide() + I::Wide::ONE) / I::Wide::TWO) +} +pub(super) fn stroke_padding_with_math>(style: &IntStrokeStyle) -> I::Wide { + let radius = stroke_radius(style); + let cap_padding = |cap: &IntLineCap| match cap { + IntLineCap::Square => radius.to_wide() * I::Wide::TWO, + IntLineCap::Custom(points) => points + .iter() + .map(|p| { + let d = i_float::int::vector::IntVector::::new(p.x.to_wide(), p.y.to_wide()).sqr_length(); + let root = d.isqrt(); + I::Wide::from_uint(root) + + if root * root < d { + I::Wide::ONE + } else { + I::Wide::ZERO + } + }) + .max() + .unwrap_or(I::Wide::ZERO), + _ => radius.to_wide(), + }; + M::guard_padding( + Join::::padding(style.join, radius) + .max(cap_padding(&style.start_cap)) + .max(cap_padding(&style.end_cap)), + ) +} diff --git a/iOverlay/src/mesh/int/stroke/build.rs b/iOverlay/src/mesh/int/stroke/build.rs new file mode 100644 index 00000000..b76495c6 --- /dev/null +++ b/iOverlay/src/mesh/int/stroke/build.rs @@ -0,0 +1,153 @@ +#[cfg(debug_assertions)] +use super::bounds::stroke_padding_with_math; +use super::{builder::StrokeBuilder, offset::IntStrokeOffset}; +use crate::core::{ + integer::OverlayInt, + overlay::{IntOverlayOptions, Overlay}, +}; +use crate::mesh::int::{ + math::{backend::MeshMath, float::FloatMath, integer::IntegerMath}, + style::IntStrokeStyle, +}; +use crate::mesh::math::MathMode; +use alloc::vec::Vec; +use i_float::int::point::IntPoint; +#[cfg(debug_assertions)] +use i_float::int::rect::IntRect; + +pub(super) trait BuildStrokeOverlay: IntStrokeOffset { + fn build_stroke_overlay( + &self, + style: &IntStrokeStyle, + closed: bool, + options: IntOverlayOptions, + ) -> Overlay { + build_stroke_overlay_iter( + self.iter_paths().map(|path| path.iter().copied()), + style, + closed, + options, + ) + } +} +impl + ?Sized> BuildStrokeOverlay for S {} + +/// Builds one overlay from paths consumed once, without retaining input points. +pub(crate) fn build_stroke_overlay_iter( + paths: Paths, + style: &IntStrokeStyle, + closed: bool, + options: IntOverlayOptions, +) -> Overlay +where + I: OverlayInt, + Paths: IntoIterator, + Path: IntoIterator>, +{ + match style.math { + MathMode::Integer => { + build_stroke_overlay_with_math::(paths, style, closed, options) + } + MathMode::Float => { + build_stroke_overlay_with_math::(paths, style, closed, options) + } + } +} + +fn build_stroke_overlay_with_math( + paths: Paths, + style: &IntStrokeStyle, + closed: bool, + options: IntOverlayOptions, +) -> Overlay +where + I: OverlayInt, + M: MeshMath, + Paths: IntoIterator, + Path: IntoIterator>, +{ + let mut builder = StrokeBuilder::::new(style); + let mut segments = Vec::new(); + #[cfg(debug_assertions)] + let padding = stroke_padding_with_math::(style); + for path in paths { + #[cfg(debug_assertions)] + let path = path.into_iter().inspect(|point| { + // Checking each expanded point is equivalent to checking the expanded + // input rectangle, and happens before the point enters geometry math. + debug_assert!( + crate::mesh::int::bounds::expanded_is_safe(IntRect::with_point(*point), padding), + "stroke bounds exceed the safe coordinate range" + ); + }); + builder.build(path, closed, &mut segments); + } + let mut overlay = Overlay::with_segments(segments); + overlay.options = options; + overlay +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::{fill_rule::FillRule, overlay_rule::OverlayRule}; + use alloc::vec; + use core::cell::Cell; + + #[test] + fn one_pass_paths_match_resource_strokes() { + let paths = vec![ + vec![], + vec![IntPoint::new(0, 0)], + vec![ + IntPoint::new(0, 0), + IntPoint::new(0, 0), + IntPoint::new(8192, 0), + IntPoint::new(8192, 8192), + ], + vec![IntPoint::new(4096, -4096), IntPoint::new(4096, 4096)], + vec![], + ]; + for math in [MathMode::Integer, MathMode::Float] { + let style = IntStrokeStyle::new(1024).math(math); + for closed in [false, true] { + let expected = paths.stroke(&style, closed).unwrap(); + let visited = Cell::new(0); + let visited_ref = &visited; + let mut expected_visited = 0; + let iter = paths.clone().into_iter().map(move |path| { + // Finish each path before requesting the next one. + assert_eq!(visited_ref.get(), expected_visited); + expected_visited += path.len(); + let mut points = path.into_iter(); + // A single-pass point iterator; no Clone or slice access required. + core::iter::from_fn(move || { + let point = points.next()?; + visited_ref.set(visited_ref.get() + 1); + Some(point) + }) + }); + let actual = build_stroke_overlay_iter(iter, &style, closed, Default::default()) + .overlay(OverlayRule::Subject, FillRule::Positive); + assert_eq!(actual, expected); + assert_eq!(visited.get(), paths.iter().map(Vec::len).sum::()); + } + } + } + + #[cfg(debug_assertions)] + #[test] + #[should_panic(expected = "stroke bounds exceed the safe coordinate range")] + fn iterator_checks_range_before_building_section() { + let paths = [[IntPoint::new(0, 0), IntPoint::new(i32::MAX, 0)]]; + build_stroke_overlay_iter(paths, &IntStrokeStyle::new(1024), false, Default::default()); + } + + #[cfg(debug_assertions)] + #[test] + #[should_panic(expected = "stroke bounds exceed the safe coordinate range")] + fn zero_radius_still_checks_range() { + let paths = [[IntPoint::new(i32::MAX, 0)]]; + build_stroke_overlay_iter(paths, &IntStrokeStyle::new(0), false, Default::default()); + } +} diff --git a/iOverlay/src/mesh/int/stroke/builder.rs b/iOverlay/src/mesh/int/stroke/builder.rs index eb68c917..08dd7518 100644 --- a/iOverlay/src/mesh/int/stroke/builder.rs +++ b/iOverlay/src/mesh/int/stroke/builder.rs @@ -1,107 +1,12 @@ +use super::{bounds::stroke_radius, builder_join::JoinBuilder, cap::Cap, section::Section}; use crate::mesh::int::{ - arc::{ArcDirection, ArcMath}, join::Join, - math::{backend::MeshMath, integer::IntegerMath, point}, - style::{IntLineCap, IntStrokeStyle}, + math::{backend::MeshMath, integer::IntegerMath}, + style::IntStrokeStyle, }; -use crate::mesh::subject::SubjectSegments; use crate::segm::{boolean::ShapeCountBoolean, segment::Segment}; use alloc::vec::Vec; -use i_float::int::number::{int::IntNumber, uint::UIntNumber, wide_int::WideIntNumber}; -use i_float::int::{point::IntPoint, unit_vector::UnitIntVector}; - -#[derive(Clone, Copy)] -struct Section { - a: IntPoint, - b: IntPoint, - a_left: IntPoint, - a_right: IntPoint, - b_left: IntPoint, - b_right: IntPoint, - dir: UnitIntVector, -} - -impl Section { - fn new>(a: IntPoint, b: IntPoint, radius: I) -> Self { - let dir = M::normalize(b - a).expect("unique section"); - let v = M::scale(dir, radius); - Self { - a, - b, - dir, - a_left: point(a, -v.y, v.x), - a_right: point(a, v.y, -v.x), - b_left: point(b, -v.y, v.x), - b_right: point(b, v.y, -v.x), - } - } - fn add(&self, segments: &mut Vec>) { - for (a, b) in [(self.a_right, self.b_right), (self.b_left, self.a_left)] { - segments.push_non_degenerate(a, b); - } - } -} - -enum Cap> { - Butt, - Square, - Round(M::Arc), - Custom(alloc::rc::Rc<[IntPoint]>), -} -impl> Cap { - fn new(cap: &IntLineCap) -> Self { - match cap { - IntLineCap::Butt => Self::Butt, - IntLineCap::Square => Self::Square, - IntLineCap::Round(options) => Self::Round(M::Arc::new(*options)), - IntLineCap::Custom(points) => Self::Custom(points.clone()), - } - } - fn add( - &mut self, - center: IntPoint, - from: IntPoint, - to: IntPoint, - outward: UnitIntVector, - radius: I, - segments: &mut Vec>, - ) { - if matches!(self, Self::Butt) { - segments.push_non_degenerate(from, to); - return; - } - let mut previous = from; - match self { - Self::Round(arc) => { - let a = M::normalize(from - center).expect("positive radius"); - let b = M::normalize(to - center).expect("positive radius"); - for &dir in arc.build(a, b, ArcDirection::Counterclockwise) { - let offset = M::scale(dir, radius); - let next = point(center, offset.x, offset.y); - segments.push_non_degenerate(previous, next); - previous = next; - } - } - Self::Square => { - let v = M::scale(outward, radius); - for next in [point(from, v.x, v.y), point(to, v.x, v.y)] { - segments.push_non_degenerate(previous, next); - previous = next; - } - } - Self::Custom(points) => { - for p in points.iter() { - let v = M::rotate(outward, *p); - let next = point(center, v.x, v.y); - segments.push_non_degenerate(previous, next); - previous = next; - } - } - Self::Butt => {} - } - segments.push_non_degenerate(previous, to); - } -} +use i_float::int::{number::int::IntNumber, point::IntPoint}; pub(super) struct StrokeBuilder = IntegerMath> { radius: I, @@ -111,39 +16,9 @@ pub(super) struct StrokeBuilder = IntegerMath> { sections: Vec>, } impl> StrokeBuilder { - pub(super) fn radius(style: &IntStrokeStyle) -> I { - I::from_wide((style.width.max(I::ZERO).to_wide() + I::Wide::ONE) / I::Wide::TWO) - } - pub(super) fn padding(style: &IntStrokeStyle) -> I::Wide { - let radius = Self::radius(style); - let cap_padding = |cap: &IntLineCap| match cap { - IntLineCap::Square => radius.to_wide() * I::Wide::TWO, - IntLineCap::Custom(points) => points - .iter() - .map(|p| { - let d = - i_float::int::vector::IntVector::::new(p.x.to_wide(), p.y.to_wide()).sqr_length(); - let root = d.isqrt(); - I::Wide::from_uint(root) - + if root * root < d { - I::Wide::ONE - } else { - I::Wide::ZERO - } - }) - .max() - .unwrap_or(I::Wide::ZERO), - _ => radius.to_wide(), - }; - M::guard_padding( - Join::::padding(style.join, radius) - .max(cap_padding(&style.start_cap)) - .max(cap_padding(&style.end_cap)), - ) - } pub(super) fn new(style: &IntStrokeStyle) -> Self { Self { - radius: Self::radius(style), + radius: stroke_radius(style), join: Join::new(style.join), start: Cap::new(&style.start_cap), end: Cap::new(&style.end_cap), @@ -185,12 +60,13 @@ impl> StrokeBuilder { s.add(segments); } for i in 1..self.sections.len() { - self.add_join(self.sections[i - 1], self.sections[i], segments); + self.join + .add_join(self.sections[i - 1], self.sections[i], self.radius, segments); } let first = self.sections[0]; let last = *self.sections.last().unwrap(); if closed { - self.add_join(last, first, segments); + self.join.add_join(last, first, self.radius, segments); } else { let backward = M::normalize(first.a - first.b).unwrap(); self.start.add( @@ -205,40 +81,15 @@ impl> StrokeBuilder { .add(last.b, last.b_right, last.b_left, last.dir, self.radius, segments); } } - fn add_join(&mut self, a: Section, b: Section, segments: &mut Vec>) { - let cross = (a.b - a.a).cross_product(b.b - b.a); - let (from, to, incoming, outgoing) = if cross >= I::Wide::ZERO { - (a.b_right, b.a_right, a.dir, b.dir) - } else { - ( - b.a_left, - a.b_left, - M::normalize(b.a - b.b).unwrap(), - M::normalize(a.a - a.b).unwrap(), - ) - }; - if cross >= I::Wide::ZERO { - segments.push_non_degenerate(b.a_left, a.b_left); - } else { - segments.push_non_degenerate(a.b_right, b.a_right); - } - self.join.add( - a.b, - from, - to, - incoming, - outgoing, - self.radius, - ArcDirection::Counterclockwise, - segments, - ); - } } #[cfg(test)] mod tests { use super::*; - use crate::mesh::int::{arc::ArcOptions, style::IntLineJoin}; + use crate::mesh::int::{ + arc::ArcOptions, + style::{IntLineCap, IntLineJoin}, + }; use i_float::int::angle::Angle; #[test] diff --git a/iOverlay/src/mesh/int/stroke/builder_join.rs b/iOverlay/src/mesh/int/stroke/builder_join.rs new file mode 100644 index 00000000..fe638808 --- /dev/null +++ b/iOverlay/src/mesh/int/stroke/builder_join.rs @@ -0,0 +1,53 @@ +use super::section::Section; +use crate::mesh::int::{arc::ArcDirection, join::Join, math::backend::MeshMath}; +use crate::mesh::subject::SubjectSegments; +use crate::segm::{boolean::ShapeCountBoolean, segment::Segment}; +use alloc::vec::Vec; +use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; + +pub(super) trait JoinBuilder { + fn add_join( + &mut self, + a: Section, + b: Section, + radius: I, + segments: &mut Vec>, + ); +} + +impl> JoinBuilder for Join { + fn add_join( + &mut self, + a: Section, + b: Section, + radius: I, + segments: &mut Vec>, + ) { + let cross = (a.b - a.a).cross_product(b.b - b.a); + let (from, to, incoming, outgoing) = if cross >= I::Wide::ZERO { + (a.b_right, b.a_right, a.dir, b.dir) + } else { + ( + b.a_left, + a.b_left, + M::normalize(b.a - b.b).unwrap(), + M::normalize(a.a - a.b).unwrap(), + ) + }; + if cross >= I::Wide::ZERO { + segments.push_non_degenerate(b.a_left, a.b_left); + } else { + segments.push_non_degenerate(a.b_right, b.a_right); + } + self.add( + a.b, + from, + to, + incoming, + outgoing, + radius, + ArcDirection::Counterclockwise, + segments, + ); + } +} diff --git a/iOverlay/src/mesh/int/stroke/cap.rs b/iOverlay/src/mesh/int/stroke/cap.rs new file mode 100644 index 00000000..9d07f8ff --- /dev/null +++ b/iOverlay/src/mesh/int/stroke/cap.rs @@ -0,0 +1,70 @@ +use crate::mesh::int::{ + arc::{ArcDirection, ArcMath}, + math::{backend::MeshMath, point}, + style::IntLineCap, +}; +use crate::mesh::subject::SubjectSegments; +use crate::segm::{boolean::ShapeCountBoolean, segment::Segment}; +use alloc::vec::Vec; +use i_float::int::{number::int::IntNumber, point::IntPoint, unit_vector::UnitIntVector}; + +pub(super) enum Cap> { + Butt, + Square, + Round(M::Arc), + Custom(alloc::rc::Rc<[IntPoint]>), +} +impl> Cap { + pub(super) fn new(cap: &IntLineCap) -> Self { + match cap { + IntLineCap::Butt => Self::Butt, + IntLineCap::Square => Self::Square, + IntLineCap::Round(options) => Self::Round(M::Arc::new(*options)), + IntLineCap::Custom(points) => Self::Custom(points.clone()), + } + } + pub(super) fn add( + &mut self, + center: IntPoint, + from: IntPoint, + to: IntPoint, + outward: UnitIntVector, + radius: I, + segments: &mut Vec>, + ) { + if matches!(self, Self::Butt) { + segments.push_non_degenerate(from, to); + return; + } + let mut previous = from; + match self { + Self::Round(arc) => { + let a = M::normalize(from - center).expect("positive radius"); + let b = M::normalize(to - center).expect("positive radius"); + for &dir in arc.build(a, b, ArcDirection::Counterclockwise) { + let offset = M::scale(dir, radius); + let next = point(center, offset.x, offset.y); + segments.push_non_degenerate(previous, next); + previous = next; + } + } + Self::Square => { + let v = M::scale(outward, radius); + for next in [point(from, v.x, v.y), point(to, v.x, v.y)] { + segments.push_non_degenerate(previous, next); + previous = next; + } + } + Self::Custom(points) => { + for p in points.iter() { + let v = M::rotate(outward, *p); + let next = point(center, v.x, v.y); + segments.push_non_degenerate(previous, next); + previous = next; + } + } + Self::Butt => {} + } + segments.push_non_degenerate(previous, to); + } +} diff --git a/iOverlay/src/mesh/int/stroke/mod.rs b/iOverlay/src/mesh/int/stroke/mod.rs index 5c5beb80..94c5c18f 100644 --- a/iOverlay/src/mesh/int/stroke/mod.rs +++ b/iOverlay/src/mesh/int/stroke/mod.rs @@ -1,5 +1,9 @@ +mod bounds; +mod build; mod builder; +mod builder_join; +mod cap; pub mod offset; +mod section; -#[cfg(test)] -mod bench; +pub(crate) use build::build_stroke_overlay_iter; diff --git a/iOverlay/src/mesh/int/stroke/offset.rs b/iOverlay/src/mesh/int/stroke/offset.rs index b6b1ca51..a296ce15 100644 --- a/iOverlay/src/mesh/int/stroke/offset.rs +++ b/iOverlay/src/mesh/int/stroke/offset.rs @@ -1,16 +1,9 @@ -use super::builder::StrokeBuilder; +use super::{bounds::StrokeBounds, build::BuildStrokeOverlay}; use crate::core::{ - fill_rule::FillRule, - integer::OverlayInt, - overlay::{IntOverlayOptions, Overlay}, - overlay_rule::OverlayRule, + fill_rule::FillRule, integer::OverlayInt, overlay::IntOverlayOptions, overlay_rule::OverlayRule, }; -use crate::mesh::int::math::{backend::MeshMath, float::FloatMath, integer::IntegerMath}; use crate::mesh::int::outline::offset::IntOutlineError; use crate::mesh::int::style::IntStrokeStyle; -use crate::mesh::math::MathMode; -use alloc::vec::Vec; -use i_float::int::{point::IntPoint, rect::IntRect}; use i_shape::{ flat::buffer::FlatContoursBuffer, int::shape::IntShapes, source::int::resource::IntShapeResource, }; @@ -32,12 +25,7 @@ pub type IntStrokeError = IntOutlineError; /// ``` pub trait IntStrokeOffset: IntShapeResource { fn validate_stroke(&self, style: &IntStrokeStyle) -> Result<(), IntStrokeError> { - if let Some(rect) = IntRect::with_iter(self.iter_paths().flatten()) { - if !crate::mesh::int::bounds::expanded_is_safe(rect, stroke_padding(style)) { - return Err(IntStrokeError::CoordinateOutOfRange); - } - } - Ok(()) + self.validate_stroke_bounds(style) } fn stroke(&self, style: &IntStrokeStyle, closed: bool) -> Result, IntStrokeError> { self.stroke_custom(style, closed, Default::default()) @@ -76,146 +64,3 @@ pub trait IntStrokeOffset: IntShapeResource { } } impl + ?Sized> IntStrokeOffset for S {} - -trait BuildStrokeOverlay: IntStrokeOffset { - fn build_stroke_overlay( - &self, - style: &IntStrokeStyle, - closed: bool, - options: IntOverlayOptions, - ) -> Overlay { - build_stroke_overlay_iter( - self.iter_paths().map(|path| path.iter().copied()), - style, - closed, - options, - ) - } -} -impl + ?Sized> BuildStrokeOverlay for S {} - -/// Builds one overlay from paths consumed once, without retaining input points. -pub(crate) fn build_stroke_overlay_iter( - paths: Paths, - style: &IntStrokeStyle, - closed: bool, - options: IntOverlayOptions, -) -> Overlay -where - I: OverlayInt, - Paths: IntoIterator, - Path: IntoIterator>, -{ - match style.math { - MathMode::Integer => { - build_stroke_overlay_with_math::(paths, style, closed, options) - } - MathMode::Float => { - build_stroke_overlay_with_math::(paths, style, closed, options) - } - } -} - -fn stroke_padding(style: &IntStrokeStyle) -> I::Wide { - match style.math { - MathMode::Integer => StrokeBuilder::::padding(style), - MathMode::Float => StrokeBuilder::::padding(style), - } -} - -fn build_stroke_overlay_with_math( - paths: Paths, - style: &IntStrokeStyle, - closed: bool, - options: IntOverlayOptions, -) -> Overlay -where - I: OverlayInt, - M: MeshMath, - Paths: IntoIterator, - Path: IntoIterator>, -{ - let mut builder = StrokeBuilder::::new(style); - let mut segments = Vec::new(); - #[cfg(debug_assertions)] - let padding = StrokeBuilder::::padding(style); - for path in paths { - #[cfg(debug_assertions)] - let path = path.into_iter().inspect(|point| { - // Checking each expanded point is equivalent to checking the expanded - // input rectangle, and happens before the point enters geometry math. - debug_assert!( - crate::mesh::int::bounds::expanded_is_safe(IntRect::with_point(*point), padding), - "stroke bounds exceed the safe coordinate range" - ); - }); - builder.build(path, closed, &mut segments); - } - let mut overlay = Overlay::with_segments(segments); - overlay.options = options; - overlay -} - -#[cfg(test)] -mod tests { - use super::*; - use alloc::vec; - use core::cell::Cell; - - #[test] - fn one_pass_paths_match_resource_strokes() { - let paths = vec![ - vec![], - vec![IntPoint::new(0, 0)], - vec![ - IntPoint::new(0, 0), - IntPoint::new(0, 0), - IntPoint::new(8192, 0), - IntPoint::new(8192, 8192), - ], - vec![IntPoint::new(4096, -4096), IntPoint::new(4096, 4096)], - vec![], - ]; - for math in [MathMode::Integer, MathMode::Float] { - let style = IntStrokeStyle::new(1024).math(math); - for closed in [false, true] { - let expected = paths.stroke(&style, closed).unwrap(); - let visited = Cell::new(0); - let visited_ref = &visited; - let mut expected_visited = 0; - let iter = paths.clone().into_iter().map(move |path| { - // Finish each path before requesting the next one. - assert_eq!(visited_ref.get(), expected_visited); - expected_visited += path.len(); - let mut points = path.into_iter(); - // A single-pass point iterator; no Clone or slice access required. - core::iter::from_fn(move || { - let point = points.next()?; - visited_ref.set(visited_ref.get() + 1); - Some(point) - }) - }); - let actual = build_stroke_overlay_iter(iter, &style, closed, Default::default()) - .overlay(OverlayRule::Subject, FillRule::Positive); - assert_eq!(actual, expected); - assert_eq!(visited.get(), paths.iter().map(Vec::len).sum::()); - } - } - } - - #[cfg(debug_assertions)] - #[test] - #[should_panic(expected = "stroke bounds exceed the safe coordinate range")] - fn iterator_checks_range_before_building_section() { - let paths = [[IntPoint::new(0, 0), IntPoint::new(i32::MAX, 0)]]; - build_stroke_overlay_iter(paths, &IntStrokeStyle::new(1024), false, Default::default()); - } - - #[cfg(debug_assertions)] - #[test] - #[should_panic(expected = "stroke bounds exceed the safe coordinate range")] - fn zero_radius_still_checks_range() { - let paths = [[IntPoint::new(i32::MAX, 0)]]; - build_stroke_overlay_iter(paths, &IntStrokeStyle::new(0), false, Default::default()); - } -} diff --git a/iOverlay/src/mesh/int/stroke/section.rs b/iOverlay/src/mesh/int/stroke/section.rs new file mode 100644 index 00000000..82792bee --- /dev/null +++ b/iOverlay/src/mesh/int/stroke/section.rs @@ -0,0 +1,37 @@ +use crate::mesh::int::math::{backend::MeshMath, point}; +use crate::mesh::subject::SubjectSegments; +use crate::segm::{boolean::ShapeCountBoolean, segment::Segment}; +use alloc::vec::Vec; +use i_float::int::{number::int::IntNumber, point::IntPoint, unit_vector::UnitIntVector}; + +#[derive(Clone, Copy)] +pub(super) struct Section { + pub(super) a: IntPoint, + pub(super) b: IntPoint, + pub(super) a_left: IntPoint, + pub(super) a_right: IntPoint, + pub(super) b_left: IntPoint, + pub(super) b_right: IntPoint, + pub(super) dir: UnitIntVector, +} + +impl Section { + pub(super) fn new>(a: IntPoint, b: IntPoint, radius: I) -> Self { + let dir = M::normalize(b - a).expect("unique section"); + let v = M::scale(dir, radius); + Self { + a, + b, + dir, + a_left: point(a, -v.y, v.x), + a_right: point(a, v.y, -v.x), + b_left: point(b, -v.y, v.x), + b_right: point(b, v.y, -v.x), + } + } + pub(super) fn add(&self, segments: &mut Vec>) { + for (a, b) in [(self.a_right, self.b_right), (self.b_left, self.a_left)] { + segments.push_non_degenerate(a, b); + } + } +} diff --git a/iOverlay/src/mesh/int/stroke/bench.rs b/iOverlay/tests/stroke_bench.rs similarity index 60% rename from iOverlay/src/mesh/int/stroke/bench.rs rename to iOverlay/tests/stroke_bench.rs index bab05b01..2e268a32 100644 --- a/iOverlay/src/mesh/int/stroke/bench.rs +++ b/iOverlay/tests/stroke_bench.rs @@ -1,18 +1,15 @@ //! Manual comparison; run with release optimizations and an otherwise idle machine. -extern crate std; -use super::offset::build_stroke_overlay_iter; -use crate::core::{fill_rule::FillRule, integer::OverlayInt, overlay_rule::OverlayRule}; -use crate::mesh::int::{ +use core::hint::black_box; +use i_float::float::number::FloatNumber; +use i_float::int::{angle::Angle, point::IntPoint, unit_vector::UnitIntVector}; +use i_overlay::core::integer::OverlayInt; +use i_overlay::mesh::int::{ arc::ArcOptions, - math::{backend::MeshMath, float::FloatMath, integer::IntegerMath}, + stroke::offset::IntStrokeOffset, style::{IntLineCap, IntLineJoin, IntStrokeStyle}, }; -use crate::mesh::math::MathMode; -use alloc::vec::Vec; -use core::hint::black_box; -use i_float::float::number::FloatNumber; -use i_float::int::{angle::Angle, point::IntPoint}; -use std::{println, time::Instant}; +use i_overlay::mesh::math::MathMode; +use std::time::Instant; fn measure(mut run: impl FnMut() -> usize) -> (f64, usize) { let count = black_box(run()); @@ -55,10 +52,10 @@ fn benchmark() { for &v in &vectors { match math { MathMode::Integer => { - black_box(>::normalize(black_box(v))); + black_box(black_box(v).fast_normalize()); } MathMode::Float => { - black_box(>::normalize(black_box(v))); + black_box(UnitIntVector::normalize_with_float(black_box(v))); } } } @@ -77,37 +74,26 @@ fn benchmark() { .line_join(join) .start_cap(IntLineCap::Round(ArcOptions::default())) .end_cap(IntLineCap::Square); - for full in [false, true] { - let (us, count) = measure(|| { - let mut overlay = build_stroke_overlay_iter( - black_box(&paths).iter().map(|p| p.iter().copied()), - black_box(&style), - false, - Default::default(), - ); - if full { - overlay - .overlay(OverlayRule::Subject, FillRule::Positive) - .iter() - .flatten() - .map(Vec::len) - .sum() - } else { - overlay.segments.len() - } - }); - println!( - "i{} jagged={jagged} join={join:?} full={full} {math:?}: {us:.2} us, count={count}", - I::BITS - ); - } + let (us, count) = measure(|| { + black_box(&paths) + .stroke(black_box(&style), false) + .unwrap() + .iter() + .flatten() + .map(Vec::len) + .sum() + }); + println!( + "i{} jagged={jagged} join={join:?} stroke {math:?}: {us:.2} us, count={count}", + I::BITS + ); } } } } #[test] -#[ignore = "manual timing: cargo test --release --lib benchmark_math_modes -- --ignored --nocapture"] +#[ignore = "manual timing: cargo test --release --test stroke_bench benchmark_math_modes -- --ignored --nocapture"] fn benchmark_math_modes() { benchmark::(); benchmark::(); From f54bd4181745c5cd688aaa73891eb5b7f335a763 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Thu, 17 Sep 2026 11:04:01 +0300 Subject: [PATCH 28/44] migrate overlay editor form iced to eframe --- examples/overlay_editor/Cargo.toml | 27 +- examples/overlay_editor/README.md | 65 +++ .../overlay_editor/src/app/boolean/content.rs | 99 ++--- .../overlay_editor/src/app/boolean/control.rs | 120 +----- .../overlay_editor/src/app/boolean/mod.rs | 2 +- .../src/app/boolean/workspace.rs | 245 +++++------ examples/overlay_editor/src/app/design.rs | 190 +++------ examples/overlay_editor/src/app/main.rs | 273 +++++++++--- examples/overlay_editor/src/app/mod.rs | 2 +- .../overlay_editor/src/app/outline/content.rs | 97 ++--- .../overlay_editor/src/app/outline/control.rs | 147 ++----- .../src/app/outline/workspace.rs | 148 +++---- .../overlay_editor/src/app/string/content.rs | 98 ++--- .../overlay_editor/src/app/string/control.rs | 120 +----- examples/overlay_editor/src/app/string/mod.rs | 2 +- .../src/app/string/workspace.rs | 253 +++++------ .../overlay_editor/src/app/stroke/content.rs | 97 ++--- .../overlay_editor/src/app/stroke/control.rs | 255 +++--------- .../src/app/stroke/workspace.rs | 150 +++---- .../src/app/variable_stroke/content.rs | 101 ++--- .../src/app/variable_stroke/control.rs | 79 ++-- .../src/app/variable_stroke/workspace.rs | 152 +++---- examples/overlay_editor/src/data/boolean.rs | 1 + examples/overlay_editor/src/data/outline.rs | 1 + examples/overlay_editor/src/data/string.rs | 1 + examples/overlay_editor/src/data/stroke.rs | 1 + .../src/data/variable_stroke.rs | 1 + examples/overlay_editor/src/draw/path.rs | 301 ++++++------- examples/overlay_editor/src/draw/shape.rs | 394 ++++++------------ .../overlay_editor/src/draw/varicolored.rs | 272 ++---------- examples/overlay_editor/src/draw/vectors.rs | 358 +++------------- examples/overlay_editor/src/geom/camera.rs | 75 ++-- examples/overlay_editor/src/geom/vector.rs | 19 +- examples/overlay_editor/src/lib.rs | 24 ++ examples/overlay_editor/src/main.rs | 38 +- .../overlay_editor/src/point_editor/state.rs | 308 +++----------- .../overlay_editor/src/point_editor/widget.rs | 240 ++--------- examples/overlay_editor/src/sheet/mod.rs | 2 +- examples/overlay_editor/src/sheet/state.rs | 96 ++--- examples/overlay_editor/src/sheet/widget.rs | 392 +++++++---------- examples/overlay_editor/src/web.rs | 102 +++-- examples/tests/outline/test_8.json | 12 - examples/tests/outline/test_9.json | 18 - 43 files changed, 1775 insertions(+), 3603 deletions(-) create mode 100644 examples/overlay_editor/README.md delete mode 100644 examples/tests/outline/test_8.json delete mode 100644 examples/tests/outline/test_9.json diff --git a/examples/overlay_editor/Cargo.toml b/examples/overlay_editor/Cargo.toml index fcb98aef..95f0ec02 100644 --- a/examples/overlay_editor/Cargo.toml +++ b/examples/overlay_editor/Cargo.toml @@ -10,26 +10,19 @@ lto = false codegen-units = 1 [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [dependencies] - -#iced = { path = "../../../../iced", features = ["wgpu", "advanced"] } -iced = { version = "0.14.0", features = ["wgpu", "advanced", "fira-sans"] } - +eframe = { version = "0.34.3", default-features = false, features = ["default_fonts", "wgpu", "x11", "wayland"] } serde = { version = "^1.0", features = ["derive"] } serde_json = "^1.0" - -wasm-bindgen = "~0.2.95" - -log = "0.4.22" -console_log = "^1.0.0" -console_error_panic_hook = "^0" - -#i_mesh = "^0.5.0" -#i_triangle = { version = "^0.44.0", features = ["serde"] } - -i_triangle = { path = "../../../../iShape/iTriangle/iTriangle", default-features = true, features = ["serde"] } +i_triangle = { path = "../../../../iShape/iTriangle/iTriangle", features = ["serde"] } i_mesh = { path = "../../../../iShape/iMesh/iMesh" } -#ICED_BACKEND=wgpu cargo r -r +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = ["Window", "Document", "HtmlCanvasElement", "HtmlElement", "CssStyleDeclaration", "Node"] } +log = "0.4" +console_log = "1.0" +console_error_panic_hook = "0.1" diff --git a/examples/overlay_editor/README.md b/examples/overlay_editor/README.md new file mode 100644 index 00000000..0639f10c --- /dev/null +++ b/examples/overlay_editor/README.md @@ -0,0 +1,65 @@ +# Overlay Editor + +Native and WebAssembly editor built with `eframe`/`egui`. The existing Boolean, +String, Stroke, Variable Stroke, and Outline modes share the original JSON fixtures. + +## Native + +From this directory: + +```sh +cargo run --release +``` + +Fixtures are resolved relative to `CARGO_MANIFEST_DIR`, so running with +`cargo run --manifest-path /path/to/overlay_editor/Cargo.toml` also works. + +- Drag a point to edit its integer coordinates. +- Drag empty canvas space to pan. +- Scroll over the canvas to zoom around the pointer. +- Use Up/Down to change fixtures when a parameter control does not have focus. + +## WebAssembly + +```sh +cargo check --target wasm32-unknown-unknown --lib +wasm-pack build --release --target web +``` + +The exported `WebApp` keeps its five JSON input strings. Startup now returns a +Promise; keep the instance alive and await startup to handle graphics errors: + +```js +import init, { WebApp } from "./pkg/overlay_editor.js"; +await init(); +const app = new WebApp(); +await app.start(booleanJson, stringJson, strokeJson, variableStrokeJson, outlineJson); +// app.destroy() releases the runner when removing the editor. +``` + +The runner uses ``. If it is absent, startup +creates a full-window canvas in the document body. An existing canvas can be sized +by the embedding page. + +## Rendering and layout + +`i_triangle` remains a local path dependency and uses the local `i_overlay` +checkout through its own manifest. It triangulates polygon fills, including holes. +The local `i_mesh` dependency builds contours, open paths, and arrows with +`RoundStrokeBuilder` (round joins and caps), rendered as `egui::Mesh`. Point markers +and the grid use native egui painting APIs. `iced` is no longer required. + +The original `app/*/{content,control,workspace}`, `draw`, `geom`, `sheet`, +`point_editor`, and `data` modules are retained. Parameters occupy the area above +the canvas; geometry and fixture calculations remain in the content modules. + +## Checks + +```sh +cargo test --lib +cargo check --all-targets +cargo check --target wasm32-unknown-unknown --lib +``` + +Tests cover all fixture workspaces, Boolean/String modes, polygon holes, +degenerate strokes, point dragging, panning, zoom anchoring, and arrow navigation. diff --git a/examples/overlay_editor/src/app/boolean/content.rs b/examples/overlay_editor/src/app/boolean/content.rs index 4ce07cd7..a2b968e0 100644 --- a/examples/overlay_editor/src/app/boolean/content.rs +++ b/examples/overlay_editor/src/app/boolean/content.rs @@ -1,6 +1,5 @@ use crate::app::boolean::control::ModeOption; use crate::app::boolean::workspace::WorkspaceState; -use crate::app::design; use crate::app::fill_option::FillOption; use crate::app::main::{AppMessage, EditorApp}; use crate::app::solver_option::SolverOption; @@ -8,12 +7,10 @@ use crate::data::boolean::BooleanResource; use crate::geom::camera::Camera; use crate::point_editor::point::PathsToEditorPoints; use crate::point_editor::widget::PointEditUpdate; +use eframe::egui::{self, Vec2}; use i_triangle::i_overlay::core::overlay::Overlay; use i_triangle::i_overlay::i_float::int::rect::IntRect; use i_triangle::i_overlay::i_shape::int::count::PointsCount; -use iced::widget::scrollable; -use iced::widget::{Button, Column, Container, Row, Space, Text}; -use iced::{Alignment, Length, Padding, Size, Vector}; use std::collections::HashMap; pub(crate) struct BooleanState { @@ -22,7 +19,7 @@ pub(crate) struct BooleanState { pub(crate) mode: ModeOption, pub(crate) solver: SolverOption, pub(crate) workspace: WorkspaceState, - pub(crate) size: Size, + pub(crate) size: Vec2, pub(crate) cameras: HashMap, } @@ -33,65 +30,33 @@ pub(crate) enum BooleanMessage { ModeSelected(ModeOption), SolverSelected(SolverOption), PointEdited(PointEditUpdate), - WorkspaceSized(Size), - WorkspaceZoomed(Camera), - WorkspaceDragged(Vector), + WorkspaceSized(Vec2), } impl EditorApp { - fn boolean_sidebar(&self) -> Column<'_, AppMessage> { - let count = self.app_resource.boolean.count; - let mut column = - Column::new().push(Space::new().width(Length::Fill).height(Length::Fixed(2.0))); - for index in 0..count { - let is_selected = self.state.boolean.test == index; - - column = column.push( - Container::new( - Button::new( - Text::new(format!("test_{}", index)) - .style(if is_selected { - design::style_sidebar_text_selected - } else { - design::style_sidebar_text - }) - .size(14), - ) - .width(Length::Fill) - .on_press(AppMessage::Bool(BooleanMessage::TestSelected(index))) - .style(if is_selected { - design::style_sidebar_button_selected - } else { - design::style_sidebar_button - }), - ) - .padding(self.design.action_padding()), - ); - } - - column - } - - pub(crate) fn boolean_content(&self) -> Row<'_, AppMessage> { - Row::new() - .push( - scrollable( - Container::new(self.boolean_sidebar()) - .width(Length::Fixed(160.0)) - .height(Length::Shrink) - .align_x(Alignment::Start) - .padding(Padding::new(0.0).right(8)) - .style(design::style_sidebar_background), - ) - .direction(scrollable::Direction::Vertical( - scrollable::Scrollbar::new() - .width(4) - .margin(0) - .scroller_width(4) - .anchor(scrollable::Anchor::Start), - )), - ) - .push(self.boolean_workspace()) + pub(crate) fn boolean_content(&mut self, ui: &mut egui::Ui) { + egui::Panel::left("boolean_tests") + .exact_size(150.0) + .resizable(false) + .show_inside(ui, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + for index in 0..self.app_resource.boolean.count { + let response = ui.selectable_label( + self.state.boolean.test == index, + format!("test_{index}"), + ); + if response.clicked() { + response.surrender_focus(); + self.update(AppMessage::Bool(BooleanMessage::TestSelected(index))); + } + } + }); + }); + egui::CentralPanel::default().show_inside(ui, |ui| { + self.boolean_control(ui); + ui.separator(); + self.boolean_workspace(ui); + }); } pub(crate) fn boolean_update(&mut self, message: BooleanMessage) { @@ -102,8 +67,6 @@ impl EditorApp { BooleanMessage::ModeSelected(mode) => self.boolean_update_mode(mode), BooleanMessage::PointEdited(update) => self.boolean_update_point(update), BooleanMessage::WorkspaceSized(size) => self.boolean_update_size(size), - BooleanMessage::WorkspaceZoomed(zoom) => self.boolean_update_zoom(zoom), - BooleanMessage::WorkspaceDragged(drag) => self.boolean_update_drag(drag), } } @@ -119,7 +82,7 @@ impl EditorApp { } pub(crate) fn boolean_next_test(&mut self) { - let next_test = self.state.boolean.test + 1; + let next_test = self.state.boolean.test.saturating_add(1); if next_test < self.app_resource.boolean.count { self.boolean_set_test(next_test); } @@ -132,7 +95,7 @@ impl EditorApp { } } - fn boolean_update_size(&mut self, size: Size) { + fn boolean_update_size(&mut self, size: Vec2) { self.state.boolean.size = size; let points = &self.state.boolean.workspace.points; if self.state.boolean.workspace.camera.is_empty() && !points.is_empty() { @@ -170,7 +133,7 @@ impl BooleanState { solver: SolverOption::Auto, workspace: Default::default(), cameras: HashMap::with_capacity(resource.count), - size: Size::ZERO, + size: Vec2::ZERO, }; state.load_test(0, resource); @@ -197,13 +160,15 @@ impl BooleanState { self.cameras.insert(self.test, self.workspace.camera); let mut camera = *self.cameras.get(&index).unwrap_or(&Camera::empty()); - if camera.is_empty() && self.size.width > 0.001 { + if camera.is_empty() && self.size.x > 0.001 { let rect = IntRect::with_iter(editor_points.iter().map(|p| &p.pos)) .unwrap_or(IntRect::new(-10_000, 10_000, -10_000, 10_000)); camera = Camera::new(rect, self.size); } self.workspace.camera = camera; + self.workspace.sheet_state = Default::default(); + self.workspace.point_state = Default::default(); self.test = index; } diff --git a/examples/overlay_editor/src/app/boolean/control.rs b/examples/overlay_editor/src/app/boolean/control.rs index 1809c34d..440fa267 100644 --- a/examples/overlay_editor/src/app/boolean/control.rs +++ b/examples/overlay_editor/src/app/boolean/control.rs @@ -1,104 +1,9 @@ use crate::app::boolean::content::BooleanMessage; -use crate::app::fill_option::FillOption; +use crate::app::design::{controls, select}; use crate::app::main::{AppMessage, EditorApp}; -use crate::app::solver_option::SolverOption; +use crate::app::{fill_option::FillOption, solver_option::SolverOption}; +use eframe::egui; use i_triangle::i_overlay::core::overlay_rule::OverlayRule; -use iced::widget::{pick_list, Column, Container, Row, Space, Text}; -use iced::{Alignment, Length}; - -impl EditorApp { - pub(crate) fn boolean_control(&self) -> Column<'_, AppMessage> { - let solver_pick_list = Row::new() - .push( - Text::new("Solver:") - .width(Length::Fixed(90.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &SolverOption::ALL[..], - Some(self.state.boolean.solver), - on_select_solver, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - let fill_pick_list = Row::new() - .push( - Text::new("Fill Rule:") - .width(Length::Fixed(90.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &FillOption::ALL[..], - Some(self.state.boolean.fill), - on_select_fill, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - let mode_pick_list = Row::new() - .push( - Text::new("Mode:") - .width(Length::Fixed(90.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &ModeOption::ALL[..], - Some(self.state.boolean.mode), - on_select_mode, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - Column::new() - .push(solver_pick_list) - .push( - Space::new() - .width(Length::Shrink) - .height(Length::Fixed(4.0)), - ) - .push(fill_pick_list) - .push( - Space::new() - .width(Length::Shrink) - .height(Length::Fixed(4.0)), - ) - .push(mode_pick_list) - } -} - -fn on_select_fill(option: FillOption) -> AppMessage { - AppMessage::Bool(BooleanMessage::FillSelected(option)) -} - -fn on_select_mode(option: ModeOption) -> AppMessage { - AppMessage::Bool(BooleanMessage::ModeSelected(option)) -} - -fn on_select_solver(option: SolverOption) -> AppMessage { - AppMessage::Bool(BooleanMessage::SolverSelected(option)) -} #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(crate) enum ModeOption { @@ -160,3 +65,22 @@ impl std::fmt::Display for ModeOption { ) } } + +impl EditorApp { + pub(crate) fn boolean_control(&mut self, ui: &mut egui::Ui) { + controls(ui, "boolean_controls", |ui| { + let mut solver = self.state.boolean.solver; + if select(ui, "Solver", &mut solver, &SolverOption::ALL) { + self.update(AppMessage::Bool(BooleanMessage::SolverSelected(solver))); + } + let mut fill = self.state.boolean.fill; + if select(ui, "Fill Rule", &mut fill, &FillOption::ALL) { + self.update(AppMessage::Bool(BooleanMessage::FillSelected(fill))); + } + let mut mode = self.state.boolean.mode; + if select(ui, "Mode", &mut mode, &ModeOption::ALL) { + self.update(AppMessage::Bool(BooleanMessage::ModeSelected(mode))); + } + }); + } +} diff --git a/examples/overlay_editor/src/app/boolean/mod.rs b/examples/overlay_editor/src/app/boolean/mod.rs index 2ac0d54b..7c23db47 100644 --- a/examples/overlay_editor/src/app/boolean/mod.rs +++ b/examples/overlay_editor/src/app/boolean/mod.rs @@ -1,3 +1,3 @@ pub(crate) mod content; -mod control; +pub(super) mod control; mod workspace; diff --git a/examples/overlay_editor/src/app/boolean/workspace.rs b/examples/overlay_editor/src/app/boolean/workspace.rs index 27720ae5..3a633f42 100644 --- a/examples/overlay_editor/src/app/boolean/workspace.rs +++ b/examples/overlay_editor/src/app/boolean/workspace.rs @@ -1,19 +1,18 @@ use crate::app::boolean::content::BooleanMessage; use crate::app::boolean::control::ModeOption; -use crate::app::design::{style_sheet_background, Design}; +use crate::app::design::Design; use crate::app::main::{AppMessage, EditorApp}; use crate::draw::shape::ShapeWidget; use crate::draw::vectors::VectorsWidget; use crate::geom::camera::Camera; use crate::point_editor::point::EditorPoint; -use crate::point_editor::widget::{PointEditUpdate, PointsEditorWidget}; +use crate::point_editor::{state::PointsEditorState, widget::PointsEditorWidget}; +use crate::sheet::state::SheetState; use crate::sheet::widget::SheetWidget; +use eframe::egui; use i_triangle::i_overlay::i_shape::int::count::IntShapes as RawIntShapes; use i_triangle::i_overlay::i_shape::int::path::IntPaths as RawIntPaths; use i_triangle::i_overlay::vector::edge::DataVectorEdge; -use iced::widget::Container; -use iced::widget::Stack; -use iced::{Length, Padding, Size, Vector}; type IntPaths = RawIntPaths; type IntShapes = RawIntShapes; @@ -21,6 +20,8 @@ type VectorEdge = DataVectorEdge; pub(crate) struct WorkspaceState { pub(crate) camera: Camera, + pub(crate) sheet_state: SheetState, + pub(crate) point_state: PointsEditorState, pub(crate) subj: IntPaths, pub(crate) clip: IntPaths, pub(crate) solution: IntShapes, @@ -29,161 +30,107 @@ pub(crate) struct WorkspaceState { } impl EditorApp { - pub(crate) fn boolean_workspace(&self) -> Container<'_, AppMessage> { - Container::new({ - let mut stack = Stack::new(); - stack = stack.push( - Container::new(SheetWidget::new( - self.state.boolean.workspace.camera, - Design::negative_color().scale_alpha(0.5), - on_update_size, - on_update_zoom, - on_update_drag, - )) - .width(Length::Fill) - .height(Length::Fill), - ); - if self.state.boolean.workspace.camera.is_not_empty() { - match self.state.boolean.mode { - ModeOption::Edit => { - stack = stack - .push( - Container::new(ShapeWidget::with_paths( - &self.state.boolean.workspace.subj, - self.state.boolean.workspace.camera, - Some(self.state.boolean.fill.fill_rule()), - Some(Design::subject_color().scale_alpha(0.2)), - Some(Design::subject_color()), - 2.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ) - .push( - Container::new(ShapeWidget::with_paths( - &self.state.boolean.workspace.clip, - self.state.boolean.workspace.camera, - Some(self.state.boolean.fill.fill_rule()), - Some(Design::clip_color().scale_alpha(0.2)), - Some(Design::clip_color()), - 2.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ) - } - ModeOption::Debug => { - stack = stack.push( - Container::new(VectorsWidget::with_vectors( - &self.state.boolean.workspace.vectors, - self.state.boolean.workspace.camera, - Design::subject_color(), - Design::clip_color(), - Design::both_color(), - 2.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ) - } - _ => { - stack = stack - .push( - Container::new(ShapeWidget::with_paths( - &self.state.boolean.workspace.subj, - self.state.boolean.workspace.camera, - Some(self.state.boolean.fill.fill_rule()), - None, - Some(Design::subject_color()), - 1.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ) - .push( - Container::new(ShapeWidget::with_paths( - &self.state.boolean.workspace.clip, - self.state.boolean.workspace.camera, - Some(self.state.boolean.fill.fill_rule()), - None, - Some(Design::clip_color()), - 1.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ) - .push( - Container::new(ShapeWidget::with_shapes( - &self.state.boolean.workspace.solution, - self.state.boolean.workspace.camera, - None, - Some(Design::solution_color().scale_alpha(0.2)), - Some(Design::solution_color()), - 2.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ) - } + pub(crate) fn boolean_workspace(&mut self, ui: &mut egui::Ui) { + self.update(AppMessage::Bool(BooleanMessage::WorkspaceSized( + ui.available_size(), + ))); + let workspace = &mut self.state.boolean.workspace; + let (painter, update) = SheetWidget::show( + ui, + &mut workspace.camera, + &workspace.points, + &mut workspace.sheet_state, + &mut workspace.point_state, + ); + if let Some(update) = update { + self.update(AppMessage::Bool(BooleanMessage::PointEdited(update))); + ui.ctx().request_repaint(); + } + let workspace = &self.state.boolean.workspace; + if workspace.camera.is_not_empty() { + match self.state.boolean.mode { + ModeOption::Edit => { + ShapeWidget::with_paths( + &workspace.subj, + workspace.camera, + Some(self.state.boolean.fill.fill_rule()), + Some(Design::subject_color().gamma_multiply(0.2)), + Some(Design::subject_color()), + 2.0, + ) + .paint(&painter); + ShapeWidget::with_paths( + &workspace.clip, + workspace.camera, + Some(self.state.boolean.fill.fill_rule()), + Some(Design::clip_color().gamma_multiply(0.2)), + Some(Design::clip_color()), + 2.0, + ) + .paint(&painter); } - stack = stack.push( - Container::new( - PointsEditorWidget::new( - &self.state.boolean.workspace.points, - self.state.boolean.workspace.camera, - on_update_point, - ) - .set_drag_color(Design::accent_color()) - .set_hover_color(Design::negative_color()), + ModeOption::Debug => { + VectorsWidget::with_vectors( + &workspace.vectors, + workspace.camera, + Design::subject_color(), + Design::clip_color(), + Design::both_color(), + 2.0, + ) + .paint(&painter); + } + _ => { + ShapeWidget::with_paths( + &workspace.subj, + workspace.camera, + Some(self.state.boolean.fill.fill_rule()), + None, + Some(Design::subject_color()), + 1.0, + ) + .paint(&painter); + ShapeWidget::with_paths( + &workspace.clip, + workspace.camera, + Some(self.state.boolean.fill.fill_rule()), + None, + Some(Design::clip_color()), + 1.0, ) - .width(Length::Fill) - .height(Length::Fill), - ); + .paint(&painter); + ShapeWidget::with_shapes( + &workspace.solution, + workspace.camera, + None, + Some(Design::solution_color().gamma_multiply(0.2)), + Some(Design::solution_color()), + 2.0, + ) + .paint(&painter); + } } - - stack.push( - Container::new(self.boolean_control()) - .width(Length::Shrink) - .height(Length::Shrink) - .padding(Padding::new(8.0)), - ) - }) - .style(style_sheet_background) + } + PointsEditorWidget::paint( + &painter, + workspace.camera, + &workspace.points, + &workspace.point_state, + ); } - - pub(super) fn boolean_update_point(&mut self, update: PointEditUpdate) { + pub(super) fn boolean_update_point( + &mut self, + update: crate::point_editor::widget::PointEditUpdate, + ) { self.state.boolean.boolean_update_point(update); } - - pub(super) fn boolean_update_zoom(&mut self, camera: Camera) { - self.state.boolean.workspace.camera = camera; - } - - pub(super) fn boolean_update_drag(&mut self, new_pos: Vector) { - self.state.boolean.workspace.camera.pos = new_pos; - } -} - -fn on_update_point(event: PointEditUpdate) -> AppMessage { - AppMessage::Bool(BooleanMessage::PointEdited(event)) -} - -fn on_update_size(size: Size) -> AppMessage { - AppMessage::Bool(BooleanMessage::WorkspaceSized(size)) -} - -fn on_update_zoom(zoom: Camera) -> AppMessage { - AppMessage::Bool(BooleanMessage::WorkspaceZoomed(zoom)) } - -fn on_update_drag(drag: Vector) -> AppMessage { - AppMessage::Bool(BooleanMessage::WorkspaceDragged(drag)) -} - impl Default for WorkspaceState { fn default() -> Self { WorkspaceState { camera: Camera::empty(), + sheet_state: Default::default(), + point_state: Default::default(), subj: vec![], clip: vec![], solution: vec![], diff --git a/examples/overlay_editor/src/app/design.rs b/examples/overlay_editor/src/app/design.rs index ca2d249d..b0cb20f2 100644 --- a/examples/overlay_editor/src/app/design.rs +++ b/examples/overlay_editor/src/app/design.rs @@ -1,150 +1,76 @@ -use iced::widget::button; -use iced::widget::container; -use iced::widget::rule; -use iced::widget::text; -use iced::{border, Background, Color, Padding, Theme}; +use eframe::egui::{self, Color32}; -pub(super) struct Design { - pub(super) action_separator: f32, -} +pub(crate) struct Design; impl Design { - pub(crate) fn solution_color() -> Color { - Color::from_rgb8(32, 199, 32) - } - - pub(crate) fn subject_color() -> Color { - Color::from_rgb8(255, 51, 51) + pub(crate) fn solution_color() -> Color32 { + Color32::from_rgb(32, 199, 32) } - - pub(crate) fn clip_color() -> Color { - Color::from_rgb8(26, 142, 255) + pub(crate) fn subject_color() -> Color32 { + Color32::from_rgb(255, 51, 51) } - - pub(crate) fn negative_color() -> Color { - if Theme::Dark.extended_palette().is_dark { - Color::from_rgb8(224, 224, 224) - } else { - Color::from_rgb8(32, 32, 32) - } - } - - pub(crate) fn accent_color() -> Color { - Color::from_rgb8(255, 140, 0) - } - - pub(crate) fn both_color() -> Color { - Color::from_rgb8(76, 217, 100) - } - - pub(super) fn new() -> Self { - Self { - action_separator: 3.0, - } - } - - pub(super) fn action_padding(&self) -> Padding { - Padding { - top: self.action_separator, - right: 2.0 * self.action_separator, - bottom: self.action_separator, - left: 2.0 * self.action_separator, - } + pub(crate) fn clip_color() -> Color32 { + Color32::from_rgb(26, 142, 255) } -} - -// Sidebar - -pub(super) fn style_sidebar_button(theme: &Theme, status: button::Status) -> button::Style { - let palette = theme.extended_palette(); - let text_color = theme.palette().text; - let base = button::Style { - background: Some(Background::Color(Color::TRANSPARENT)), - text_color, - border: border::rounded(6), - ..button::Style::default() - }; - - match status { - button::Status::Pressed | button::Status::Hovered => button::Style { - background: Some(Background::Color( - palette.background.weak.color.scale_alpha(0.2), - )), - ..base - }, - button::Status::Disabled | button::Status::Active => base, + pub(crate) fn negative_color() -> Color32 { + Color32::from_rgb(224, 224, 224) } -} - -pub(super) fn style_sidebar_button_selected( - theme: &Theme, - status: button::Status, -) -> button::Style { - let palette = theme.extended_palette(); - let base = button::Style { - background: Some(Background::Color(palette.primary.strong.color)), - text_color: palette.primary.strong.text, - border: border::rounded(6), - ..button::Style::default() - }; - - match status { - button::Status::Pressed | button::Status::Active => base, - button::Status::Hovered => button::Style { - background: Some(Background::Color(palette.primary.base.color)), - ..base - }, - button::Status::Disabled => button::Style { - background: Some(Background::Color(Color::TRANSPARENT)), - ..base - }, + pub(crate) fn accent_color() -> Color32 { + Color32::from_rgb(255, 140, 0) } -} - -pub(super) fn style_sidebar_text(theme: &Theme) -> text::Style { - let palette = theme.palette(); - text::Style { - color: Some(palette.text.scale_alpha(0.7)), + pub(crate) fn both_color() -> Color32 { + Color32::from_rgb(76, 217, 100) } } -pub(super) fn style_sidebar_text_selected(theme: &Theme) -> text::Style { - let palette = theme.palette(); - text::Style { - color: Some(palette.text), - } +const CONTROL_WIDTH: f32 = 232.0; + +pub(crate) fn controls(ui: &mut egui::Ui, id: &str, content: impl FnOnce(&mut egui::Ui)) { + ui.scope(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(8.0, 6.0); + ui.spacing_mut().interact_size = egui::vec2(64.0, 26.0); + ui.spacing_mut().slider_width = 160.0; + egui::Grid::new(id) + .num_columns(2) + .min_col_width(130.0) + .min_row_height(26.0) + .spacing([16.0, 6.0]) + .show(ui, content); + }); } -pub(super) fn style_sidebar_background(theme: &Theme) -> container::Style { - container::Style::default().background( - theme - .extended_palette() - .background - .weak - .color - .scale_alpha(0.1), - ) +pub(crate) fn slider(ui: &mut egui::Ui, label: &str, slider: egui::Slider<'_>) -> bool { + let label = ui.label(label); + let changed = ui.add(slider).labelled_by(label.id).changed(); + ui.end_row(); + changed } -pub(super) fn style_separator(theme: &Theme) -> rule::Style { - let color = if theme.extended_palette().is_dark { - Color::from_rgba(0.0, 0.0, 0.0, 0.8) - } else { - Color::from_rgba(1.0, 1.0, 1.0, 0.8) - }; - - rule::Style { - color, - radius: border::Radius::new(0), - fill_mode: rule::FillMode::Padded(0), - snap: true, - } +pub(crate) fn checkbox(ui: &mut egui::Ui, label: &str, value: &mut bool) -> bool { + let label = ui.label(label); + let changed = ui.checkbox(value, "").labelled_by(label.id).changed(); + ui.end_row(); + changed } -pub(super) fn style_sheet_background(theme: &Theme) -> container::Style { - if theme.extended_palette().is_dark { - container::Style::default().background(Color::BLACK.scale_alpha(0.4)) - } else { - container::Style::default().background(Color::WHITE.scale_alpha(0.4)) - } +pub(crate) fn select( + ui: &mut egui::Ui, + label: &str, + value: &mut T, + options: &[T], +) -> bool { + let before = *value; + let label_response = ui.label(label); + egui::ComboBox::from_id_salt(label) + .width(CONTROL_WIDTH) + .selected_text(value.to_string()) + .show_ui(ui, |ui| { + for &option in options { + ui.selectable_value(value, option, option.to_string()); + } + }) + .response + .labelled_by(label_response.id); + ui.end_row(); + *value != before } diff --git a/examples/overlay_editor/src/app/main.rs b/examples/overlay_editor/src/app/main.rs index 8d57c76e..df585835 100644 --- a/examples/overlay_editor/src/app/main.rs +++ b/examples/overlay_editor/src/app/main.rs @@ -7,22 +7,14 @@ use crate::app::string::content::StringState; use crate::app::stroke::content::StrokeMessage; use crate::app::stroke::content::StrokeState; use crate::app::variable_stroke::content::{VariableStrokeMessage, VariableStrokeState}; -use iced::keyboard::key::Named; -use iced::keyboard::Key; -use iced::widget::{rule, Space}; -use iced::widget::{Button, Column, Container, Row, Text}; -use iced::{keyboard, Alignment, Element, Length}; -use iced::{Subscription, Task}; - -use crate::app::design::style_separator; -use crate::app::design::{style_sidebar_button, style_sidebar_button_selected, Design}; +use eframe::egui; + use crate::data::resource::AppResource; pub struct EditorApp { main_actions: Vec, pub(super) state: MainState, pub(super) app_resource: AppResource, - pub(super) design: Design, } pub(super) struct MainState { @@ -91,13 +83,12 @@ impl EditorApp { outline: OutlineState::new(&mut app_resource.outline), }, app_resource, - design: Design::new(), } } } impl EditorApp { - pub fn update(&mut self, message: AppMessage) -> Task { + pub(crate) fn update(&mut self, message: AppMessage) { match message { AppMessage::Main(msg) => self.update_main(msg), AppMessage::Bool(msg) => self.boolean_update(msg), @@ -120,19 +111,6 @@ impl EditorApp { MainAction::Outline => self.outline_prev_test(), }, } - - Task::none() - } - - pub fn subscription(&self) -> Subscription { - keyboard::listen().filter_map(|event| match event { - keyboard::Event::KeyPressed { key, .. } => match key { - Key::Named(Named::ArrowDown) => Some(AppMessage::NextTest), - Key::Named(Named::ArrowUp) => Some(AppMessage::PrevTest), - _ => None, - }, - _ => None, - }) } fn update_main(&mut self, message: MainMessage) { @@ -150,54 +128,211 @@ impl EditorApp { } } - pub fn view(&self) -> Element<'_, AppMessage> { - let content = Row::new().push( - Container::new(self.main_navigation()) - .width(Length::Fixed(160.0)) - .height(Length::Shrink) - .align_x(Alignment::Start), - ); + pub(crate) fn view(&mut self, ui: &mut egui::Ui) { + if !ui.ctx().egui_wants_keyboard_input() { + if ui.input(|i| i.key_pressed(egui::Key::ArrowDown)) { + self.update(AppMessage::NextTest); + } + if ui.input(|i| i.key_pressed(egui::Key::ArrowUp)) { + self.update(AppMessage::PrevTest); + } + } + egui::Panel::left("navigation") + .exact_size(150.0) + .resizable(false) + .show_inside(ui, |ui| { + for action in self.main_actions.clone() { + let response = + ui.selectable_label(self.state.selected_action == action, action.title()); + if response.clicked() { + response.surrender_focus(); + self.update(AppMessage::Main(MainMessage::ActionSelected(action))); + } + } + }); + match self.state.selected_action { + MainAction::Boolean => self.boolean_content(ui), + MainAction::String => self.string_content(ui), + MainAction::Stroke => self.stroke_content(ui), + MainAction::VariableStroke => self.variable_stroke_content(ui), + MainAction::Outline => self.outline_content(ui), + } + } +} - let content = match self.state.selected_action { - MainAction::Boolean => content - .push(rule::vertical(1).style(style_separator)) - .push(self.boolean_content()), - MainAction::String => content - .push(rule::vertical(1).style(style_separator)) - .push(self.string_content()), - MainAction::Stroke => content - .push(rule::vertical(1).style(style_separator)) - .push(self.stroke_content()), - MainAction::VariableStroke => content - .push(rule::vertical(1).style(style_separator)) - .push(self.variable_stroke_content()), - MainAction::Outline => content - .push(rule::vertical(1).style(style_separator)) - .push(self.outline_content()), - }; +impl eframe::App for EditorApp { + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.view(ui); + } +} - content.height(Length::Fill).into() +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + use eframe::egui::{self, RawInput, Rect, Vec2}; + + fn editor() -> EditorApp { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests"); + let path = |name| root.join(name).to_str().unwrap().to_owned(); + EditorApp::with_resource(AppResource::with_paths( + &path("boolean"), + &path("string"), + &path("stroke"), + &path("variable_stroke"), + &path("outline"), + )) } - fn main_navigation(&self) -> Column<'_, AppMessage> { - self.main_actions.iter().fold( - Column::new().push(Space::new().width(Length::Fill).height(Length::Fixed(2.0))), - |column, item| { - let is_selected = self.state.selected_action.eq(item); - column.push( - Container::new( - Button::new(Text::new(item.title())) - .width(Length::Fill) - .on_press(AppMessage::Main(MainMessage::ActionSelected(item.clone()))) - .style(if is_selected { - style_sidebar_button_selected - } else { - style_sidebar_button - }), - ) - .padding(self.design.action_padding()), - ) + fn render(app: &mut EditorApp, ctx: &egui::Context) { + let output = ctx.run_ui( + RawInput { + screen_rect: Some(Rect::from_min_size( + egui::Pos2::ZERO, + Vec2::new(1280.0, 800.0), + )), + ..Default::default() }, - ) + |ui| app.view(ui), + ); + let primitives = ctx.tessellate(output.shapes, output.pixels_per_point); + assert!(!primitives.is_empty()); + for primitive in primitives { + if let egui::epaint::Primitive::Mesh(mesh) = primitive.primitive { + assert!(mesh.is_valid()); + assert!( + mesh.vertices.iter().all(|vertex| vertex.pos.is_finite()), + "invalid mesh in {:?}, tests: {}, {}, {}, {}, {}; bad vertices: {:?}", + app.state.selected_action, + app.state.boolean.test, + app.state.string.test, + app.state.stroke.test, + app.state.variable_stroke.test, + app.state.outline.test, + mesh.vertices + .iter() + .filter(|v| !v.pos.is_finite()) + .take(4) + .collect::>() + ); + } + } + } + + #[test] + fn all_fixture_workspaces_render() { + let mut app = editor(); + let ctx = egui::Context::default(); + for action in app.main_actions.clone() { + app.update(AppMessage::Main(MainMessage::ActionSelected( + action.clone(), + ))); + let count = match action { + MainAction::Boolean => app.app_resource.boolean.count, + MainAction::String => app.app_resource.string.count, + MainAction::Stroke => app.app_resource.stroke.count, + MainAction::VariableStroke => app.app_resource.variable_stroke.count, + MainAction::Outline => app.app_resource.outline.count, + }; + assert!(count > 0); + for index in 0..count { + let message = match action { + MainAction::Boolean => AppMessage::Bool(BooleanMessage::TestSelected(index)), + MainAction::String => AppMessage::String(StringMessage::TestSelected(index)), + MainAction::Stroke => AppMessage::Stroke(StrokeMessage::TestSelected(index)), + MainAction::VariableStroke => { + AppMessage::VariableStroke(VariableStrokeMessage::TestSelected(index)) + } + MainAction::Outline => AppMessage::Outline(OutlineMessage::TestSelected(index)), + }; + app.update(message); + render(&mut app, &ctx); + } + } + } + + #[test] + fn boolean_and_string_render_modes() { + use crate::app::{ + boolean::control::ModeOption as BooleanMode, string::control::ModeOption as StringMode, + }; + let mut app = editor(); + let ctx = egui::Context::default(); + for mode in [ + BooleanMode::Edit, + BooleanMode::Debug, + BooleanMode::Subject, + BooleanMode::Clip, + BooleanMode::Intersect, + BooleanMode::Union, + BooleanMode::Difference, + BooleanMode::InverseDifference, + BooleanMode::Xor, + ] { + app.update(AppMessage::Bool(BooleanMessage::ModeSelected(mode))); + render(&mut app, &ctx); + } + app.update(AppMessage::Main(MainMessage::ActionSelected( + MainAction::String, + ))); + for mode in [ + StringMode::Edit, + StringMode::Debug, + StringMode::Slice, + StringMode::ClipDirect, + StringMode::ClipInvert, + ] { + app.update(AppMessage::String(StringMessage::ModeSelected(mode))); + render(&mut app, &ctx); + } + } + #[test] + fn arrow_navigation_works_after_canvas_and_sidebar_clicks() { + let mut app = editor(); + let ctx = egui::Context::default(); + let mut frame = |events| { + let _ = ctx.run_ui( + RawInput { + screen_rect: Some(Rect::from_min_size( + egui::Pos2::ZERO, + Vec2::new(1280.0, 800.0), + )), + events, + ..Default::default() + }, + |ui| app.view(ui), + ); + app.state.boolean.test + }; + frame(vec![]); + for pos in [egui::pos2(1100.0, 700.0), egui::pos2(170.0, 13.0)] { + frame(vec![egui::Event::PointerMoved(pos)]); + frame(vec![egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }]); + let before = frame(vec![egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }]); + let after = frame(vec![egui::Event::Key { + key: egui::Key::ArrowDown, + physical_key: None, + pressed: true, + repeat: false, + modifiers: Default::default(), + }]); + assert_eq!(after, before + 1); + frame(vec![egui::Event::Key { + key: egui::Key::ArrowDown, + physical_key: None, + pressed: false, + repeat: false, + modifiers: Default::default(), + }]); + } } } diff --git a/examples/overlay_editor/src/app/mod.rs b/examples/overlay_editor/src/app/mod.rs index 6f50052c..293b7b8e 100644 --- a/examples/overlay_editor/src/app/mod.rs +++ b/examples/overlay_editor/src/app/mod.rs @@ -1,5 +1,5 @@ mod boolean; -mod design; +pub(crate) mod design; mod fill_option; pub mod main; mod outline; diff --git a/examples/overlay_editor/src/app/outline/content.rs b/examples/overlay_editor/src/app/outline/content.rs index d1c4396b..dbaa6d19 100644 --- a/examples/overlay_editor/src/app/outline/content.rs +++ b/examples/overlay_editor/src/app/outline/content.rs @@ -1,4 +1,3 @@ -use crate::app::design; use crate::app::main::{AppMessage, EditorApp}; use crate::app::outline::control::JoinOption; use crate::app::outline::workspace::WorkspaceState; @@ -6,12 +5,11 @@ use crate::data::outline::OutlineResource; use crate::geom::camera::Camera; use crate::point_editor::point::PathsToEditorPoints; use crate::point_editor::widget::PointEditUpdate; +use eframe::egui::{self, Vec2}; use i_triangle::i_overlay::i_float::int::point::IntPoint; use i_triangle::i_overlay::i_float::int::rect::IntRect; use i_triangle::i_overlay::mesh::float::outline::offset::OutlineOffset; use i_triangle::i_overlay::mesh::float::style::{LineJoin, OutlineStyle}; -use iced::widget::{scrollable, Button, Column, Container, Row, Space, Text}; -use iced::{Alignment, Length, Padding, Size, Vector}; use std::collections::HashMap; pub(crate) struct OutlineState { @@ -21,7 +19,7 @@ pub(crate) struct OutlineState { pub(crate) join: JoinOption, pub(crate) join_value: u8, pub(crate) workspace: WorkspaceState, - pub(crate) size: Size, + pub(crate) size: Vec2, pub(crate) cameras: HashMap, } @@ -33,64 +31,33 @@ pub(crate) enum OutlineMessage { JoinSelected(JoinOption), JoinValueUpdated(u8), PointEdited(PointEditUpdate), - WorkspaceSized(Size), - WorkspaceZoomed(Camera), - WorkspaceDragged(Vector), + WorkspaceSized(Vec2), } impl EditorApp { - fn outline_sidebar(&self) -> Column<'_, AppMessage> { - let count = self.app_resource.outline.count; - let mut column = - Column::new().push(Space::new().width(Length::Fill).height(Length::Fixed(2.0))); - for index in 0..count { - let is_selected = self.state.outline.test == index; - column = column.push( - Container::new( - Button::new( - Text::new(format!("test_{}", index)) - .style(if is_selected { - design::style_sidebar_text_selected - } else { - design::style_sidebar_text - }) - .size(14), - ) - .width(Length::Fill) - .on_press(AppMessage::Outline(OutlineMessage::TestSelected(index))) - .style(if is_selected { - design::style_sidebar_button_selected - } else { - design::style_sidebar_button - }), - ) - .padding(self.design.action_padding()), - ); - } - - column - } - - pub(crate) fn outline_content(&self) -> Row<'_, AppMessage> { - Row::new() - .push( - scrollable( - Container::new(self.outline_sidebar()) - .width(Length::Fixed(160.0)) - .height(Length::Shrink) - .align_x(Alignment::Start) - .padding(Padding::new(0.0).right(8)) - .style(design::style_sidebar_background), - ) - .direction(scrollable::Direction::Vertical( - scrollable::Scrollbar::new() - .width(4) - .margin(0) - .scroller_width(4) - .anchor(scrollable::Anchor::Start), - )), - ) - .push(self.outline_workspace()) + pub(crate) fn outline_content(&mut self, ui: &mut egui::Ui) { + egui::Panel::left("outline_tests") + .exact_size(150.0) + .resizable(false) + .show_inside(ui, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + for index in 0..self.app_resource.outline.count { + let response = ui.selectable_label( + self.state.outline.test == index, + format!("test_{index}"), + ); + if response.clicked() { + response.surrender_focus(); + self.update(AppMessage::Outline(OutlineMessage::TestSelected(index))); + } + } + }); + }); + egui::CentralPanel::default().show_inside(ui, |ui| { + self.outline_control(ui); + ui.separator(); + self.outline_workspace(ui); + }); } pub(crate) fn outline_update(&mut self, message: OutlineMessage) { @@ -106,8 +73,6 @@ impl EditorApp { OutlineMessage::JoinValueUpdated(value) => self.outline_update_join_value(value), OutlineMessage::PointEdited(update) => self.outline_update_point(update), OutlineMessage::WorkspaceSized(size) => self.outline_update_size(size), - OutlineMessage::WorkspaceZoomed(zoom) => self.outline_update_zoom(zoom), - OutlineMessage::WorkspaceDragged(drag) => self.outline_update_drag(drag), } } @@ -123,7 +88,7 @@ impl EditorApp { } pub(crate) fn outline_next_test(&mut self) { - let next_test = self.state.outline.test + 1; + let next_test = self.state.outline.test.saturating_add(1); if next_test < self.app_resource.outline.count { self.outline_set_test(next_test); } @@ -136,7 +101,7 @@ impl EditorApp { } } - fn outline_update_size(&mut self, size: Size) { + fn outline_update_size(&mut self, size: Vec2) { self.state.outline.size = size; let points = &self.state.outline.workspace.points; if self.state.outline.workspace.camera.is_empty() && !points.is_empty() { @@ -180,7 +145,7 @@ impl OutlineState { join_value: 50, workspace: Default::default(), cameras: HashMap::with_capacity(resource.count), - size: Size::ZERO, + size: Vec2::ZERO, }; state.set_test(0, resource); @@ -217,13 +182,15 @@ impl OutlineState { self.cameras.insert(self.test, self.workspace.camera); let mut camera = *self.cameras.get(&index).unwrap_or(&Camera::empty()); - if camera.is_empty() && self.size.width > 0.001 { + if camera.is_empty() && self.size.x > 0.001 { let rect = IntRect::with_iter(editor_points.iter().map(|p| &p.pos)) .unwrap_or(IntRect::new(-10_000, 10_000, -10_000, 10_000)); camera = Camera::new(rect, self.size); } self.workspace.camera = camera; + self.workspace.sheet_state = Default::default(); + self.workspace.point_state = Default::default(); self.test = index; } diff --git a/examples/overlay_editor/src/app/outline/control.rs b/examples/overlay_editor/src/app/outline/control.rs index 622a9cf9..f7aa37c4 100644 --- a/examples/overlay_editor/src/app/outline/control.rs +++ b/examples/overlay_editor/src/app/outline/control.rs @@ -1,7 +1,7 @@ +use crate::app::design::{controls, select, slider}; use crate::app::main::{AppMessage, EditorApp}; use crate::app::outline::content::OutlineMessage; -use iced::widget::{pick_list, slider, Column, Container, Row, Space, Text}; -use iced::{Alignment, Length, Padding}; +use eframe::egui; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(crate) enum JoinOption { @@ -30,109 +30,44 @@ impl std::fmt::Display for JoinOption { } impl EditorApp { - pub(crate) fn outline_control(&self) -> Column<'_, AppMessage> { - let outer_offset_list = Row::new() - .push( - Text::new("Outer Offset:") - .width(Length::Fixed(120.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - slider( - -50.0f32..=50.0f32, - self.state.outline.outer_offset, - on_update_outer_offset, - ) - .step(0.01f32), - ) - .width(410) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - let inner_offset_list = Row::new() - .push( - Text::new("Inner Offset:") - .width(Length::Fixed(120.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - slider( - -50.0f32..=50.0f32, - self.state.outline.inner_offset, - on_update_inner_offset, - ) - .step(0.01f32), - ) - .width(410) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - let mut join_pick_list = Row::new() - .push( - Text::new("Line Join:") - .width(Length::Fixed(120.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &JoinOption::ALL[..], - Some(self.state.outline.join), - on_select_join, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - if self.state.outline.join != JoinOption::Bevel { - let slider = slider(1..=100, self.state.outline.join_value, on_update_join_value) - .default(50) - .shift_step(5); - - join_pick_list = join_pick_list.push( - Container::new(slider) - .padding(Padding::new(0.0).left(20.0)) - .width(250) - .height(Length::Fill) - .align_y(Alignment::Center), - ); - } - - Column::new() - .push(outer_offset_list) - .push(inner_offset_list) - .push( - Space::new() - .width(Length::Shrink) - .height(Length::Fixed(4.0)), - ) - .push(join_pick_list) + pub(crate) fn outline_control(&mut self, ui: &mut egui::Ui) { + controls(ui, "outline_controls", |ui| { + let mut outer_offset = self.state.outline.outer_offset; + if slider( + ui, + "Outer Offset", + egui::Slider::new(&mut outer_offset, -50.0..=50.0).step_by(0.01), + ) { + self.update(AppMessage::Outline( + OutlineMessage::OuterOffsetValueUpdated(outer_offset), + )); + } + let mut inner_offset = self.state.outline.inner_offset; + if slider( + ui, + "Inner Offset", + egui::Slider::new(&mut inner_offset, -50.0..=50.0).step_by(0.01), + ) { + self.update(AppMessage::Outline( + OutlineMessage::InnerOffsetValueUpdated(inner_offset), + )); + } + let mut join = self.state.outline.join; + if select(ui, "Line Join", &mut join, &JoinOption::ALL) { + self.update(AppMessage::Outline(OutlineMessage::JoinSelected(join))); + } + if self.state.outline.join != JoinOption::Bevel { + let mut join_value = self.state.outline.join_value; + if slider( + ui, + "Join Detail", + egui::Slider::new(&mut join_value, 1..=100), + ) { + self.update(AppMessage::Outline(OutlineMessage::JoinValueUpdated( + join_value, + ))); + } + } + }); } } - -fn on_update_outer_offset(value: f32) -> AppMessage { - AppMessage::Outline(OutlineMessage::OuterOffsetValueUpdated(value)) -} - -fn on_update_inner_offset(value: f32) -> AppMessage { - AppMessage::Outline(OutlineMessage::InnerOffsetValueUpdated(value)) -} - -fn on_select_join(option: JoinOption) -> AppMessage { - AppMessage::Outline(OutlineMessage::JoinSelected(option)) -} - -fn on_update_join_value(value: u8) -> AppMessage { - AppMessage::Outline(OutlineMessage::JoinValueUpdated(value)) -} diff --git a/examples/overlay_editor/src/app/outline/workspace.rs b/examples/overlay_editor/src/app/outline/workspace.rs index 7bac2501..f073b595 100644 --- a/examples/overlay_editor/src/app/outline/workspace.rs +++ b/examples/overlay_editor/src/app/outline/workspace.rs @@ -1,21 +1,22 @@ -use crate::app::design::{style_sheet_background, Design}; +use crate::app::design::Design; use crate::app::main::{AppMessage, EditorApp}; use crate::app::outline::content::OutlineMessage; use crate::draw::shape::ShapeWidget; use crate::geom::camera::Camera; use crate::point_editor::point::EditorPoint; -use crate::point_editor::widget::{PointEditUpdate, PointsEditorWidget}; +use crate::point_editor::{state::PointsEditorState, widget::PointsEditorWidget}; +use crate::sheet::state::SheetState; use crate::sheet::widget::SheetWidget; +use eframe::egui; use i_triangle::i_overlay::core::fill_rule::FillRule; use i_triangle::i_overlay::i_shape::int::path::IntPaths as RawIntPaths; -use iced::widget::Container; -use iced::widget::Stack; -use iced::{Length, Padding, Size, Vector}; type IntPaths = RawIntPaths; pub(crate) struct WorkspaceState { pub(crate) camera: Camera, + pub(crate) sheet_state: SheetState, + pub(crate) point_state: PointsEditorState, pub(crate) scale: f32, pub(crate) outline_input: IntPaths, pub(crate) outline_output: IntPaths, @@ -23,105 +24,64 @@ pub(crate) struct WorkspaceState { } impl EditorApp { - pub(crate) fn outline_workspace(&self) -> Container<'_, AppMessage> { - Container::new({ - let mut stack = Stack::new(); - stack = stack.push( - Container::new(SheetWidget::new( - self.state.outline.workspace.camera, - Design::negative_color().scale_alpha(0.5), - on_update_size, - on_update_zoom, - on_update_drag, - )) - .width(Length::Fill) - .height(Length::Fill), - ); - - if self.state.outline.workspace.camera.is_not_empty() { - stack = stack.push( - Container::new(ShapeWidget::with_paths( - &self.state.outline.workspace.outline_output, - self.state.outline.workspace.camera, - Some(FillRule::NonZero), - Some(Design::solution_color().scale_alpha(0.1)), - Some(Design::solution_color()), - 2.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ); - stack = stack.push( - Container::new(ShapeWidget::with_paths( - &self.state.outline.workspace.outline_input, - self.state.outline.workspace.camera, - Some(FillRule::NonZero), - Some(Design::subject_color().scale_alpha(0.1)), - Some(Design::subject_color()), - 1.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ); - stack = stack.push( - Container::new( - PointsEditorWidget::new( - &self.state.outline.workspace.points, - self.state.outline.workspace.camera, - on_update_point, - ) - .set_drag_color(Design::accent_color()) - .set_hover_color(Design::negative_color()), - ) - .width(Length::Fill) - .height(Length::Fill), - ); - } - - stack.push( - Container::new(self.outline_control()) - .width(Length::Shrink) - .height(Length::Shrink) - .padding(Padding::new(8.0)), + pub(crate) fn outline_workspace(&mut self, ui: &mut egui::Ui) { + self.update(AppMessage::Outline(OutlineMessage::WorkspaceSized( + ui.available_size(), + ))); + let workspace = &mut self.state.outline.workspace; + let (painter, update) = SheetWidget::show( + ui, + &mut workspace.camera, + &workspace.points, + &mut workspace.sheet_state, + &mut workspace.point_state, + ); + if let Some(update) = update { + self.update(AppMessage::Outline(OutlineMessage::PointEdited(update))); + ui.ctx().request_repaint(); + } + let workspace = &self.state.outline.workspace; + if workspace.camera.is_not_empty() { + ShapeWidget::with_paths( + &workspace.outline_output, + workspace.camera, + Some(FillRule::NonZero), + Some(Design::solution_color().gamma_multiply(0.1)), + Some(Design::solution_color()), + 2.0, ) - }) - .style(style_sheet_background) + .paint(&painter); + ShapeWidget::with_paths( + &workspace.outline_input, + workspace.camera, + Some(FillRule::NonZero), + Some(Design::subject_color().gamma_multiply(0.1)), + Some(Design::subject_color()), + 1.0, + ) + .paint(&painter); + } + PointsEditorWidget::paint( + &painter, + workspace.camera, + &workspace.points, + &workspace.point_state, + ); } - - pub(super) fn outline_update_point(&mut self, update: PointEditUpdate) { + pub(super) fn outline_update_point( + &mut self, + update: crate::point_editor::widget::PointEditUpdate, + ) { self.state.outline.outline_update_point(update); } - - pub(super) fn outline_update_zoom(&mut self, camera: Camera) { - self.state.outline.workspace.camera = camera; - } - - pub(super) fn outline_update_drag(&mut self, new_pos: Vector) { - self.state.outline.workspace.camera.pos = new_pos; - } } - -fn on_update_point(event: PointEditUpdate) -> AppMessage { - AppMessage::Outline(OutlineMessage::PointEdited(event)) -} - -fn on_update_size(size: Size) -> AppMessage { - AppMessage::Outline(OutlineMessage::WorkspaceSized(size)) -} - -fn on_update_zoom(zoom: Camera) -> AppMessage { - AppMessage::Outline(OutlineMessage::WorkspaceZoomed(zoom)) -} - -fn on_update_drag(drag: Vector) -> AppMessage { - AppMessage::Outline(OutlineMessage::WorkspaceDragged(drag)) -} - impl Default for WorkspaceState { fn default() -> Self { WorkspaceState { scale: 1.0, camera: Camera::empty(), + sheet_state: Default::default(), + point_state: Default::default(), outline_input: vec![], outline_output: vec![], points: vec![], diff --git a/examples/overlay_editor/src/app/string/content.rs b/examples/overlay_editor/src/app/string/content.rs index 1d475d2c..596d4a6a 100644 --- a/examples/overlay_editor/src/app/string/content.rs +++ b/examples/overlay_editor/src/app/string/content.rs @@ -1,4 +1,3 @@ -use crate::app::design; use crate::app::fill_option::FillOption; use crate::app::main::{AppMessage, EditorApp}; use crate::app::solver_option::SolverOption; @@ -8,13 +7,11 @@ use crate::data::string::StringResource; use crate::geom::camera::Camera; use crate::point_editor::point::PathsToEditorPoints; use crate::point_editor::widget::PointEditUpdate; +use eframe::egui::{self, Vec2}; use i_triangle::i_overlay::i_float::int::rect::IntRect; use i_triangle::i_overlay::i_shape::int::count::PointsCount; use i_triangle::i_overlay::string::clip::{ClipRule, IntClip}; use i_triangle::i_overlay::string::slice::IntSlice; -use iced::widget::scrollable; -use iced::widget::{Button, Column, Container, Row, Space, Text}; -use iced::{Alignment, Length, Padding, Size, Vector}; use std::collections::HashMap; pub(crate) struct StringState { @@ -23,7 +20,7 @@ pub(crate) struct StringState { pub(crate) mode: ModeOption, pub(crate) solver: SolverOption, pub(crate) workspace: WorkspaceState, - pub(crate) size: Size, + pub(crate) size: Vec2, pub(crate) cameras: HashMap, } @@ -34,64 +31,33 @@ pub(crate) enum StringMessage { ModeSelected(ModeOption), SolverSelected(SolverOption), PointEdited(PointEditUpdate), - WorkspaceSized(Size), - WorkspaceZoomed(Camera), - WorkspaceDragged(Vector), + WorkspaceSized(Vec2), } impl EditorApp { - fn string_sidebar(&self) -> Column<'_, AppMessage> { - let count = self.app_resource.string.count; - let mut column = - Column::new().push(Space::new().width(Length::Fill).height(Length::Fixed(2.0))); - for index in 0..count { - let is_selected = self.state.string.test == index; - column = column.push( - Container::new( - Button::new( - Text::new(format!("test_{}", index)) - .style(if is_selected { - design::style_sidebar_text_selected - } else { - design::style_sidebar_text - }) - .size(14), - ) - .width(Length::Fill) - .on_press(AppMessage::String(StringMessage::TestSelected(index))) - .style(if is_selected { - design::style_sidebar_button_selected - } else { - design::style_sidebar_button - }), - ) - .padding(self.design.action_padding()), - ); - } - - column - } - - pub(crate) fn string_content(&self) -> Row<'_, AppMessage> { - Row::new() - .push( - scrollable( - Container::new(self.string_sidebar()) - .width(Length::Fixed(160.0)) - .height(Length::Shrink) - .align_x(Alignment::Start) - .padding(Padding::new(0.0).right(8)) - .style(design::style_sidebar_background), - ) - .direction(scrollable::Direction::Vertical( - scrollable::Scrollbar::new() - .width(4) - .margin(0) - .scroller_width(4) - .anchor(scrollable::Anchor::Start), - )), - ) - .push(self.string_workspace()) + pub(crate) fn string_content(&mut self, ui: &mut egui::Ui) { + egui::Panel::left("string_tests") + .exact_size(150.0) + .resizable(false) + .show_inside(ui, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + for index in 0..self.app_resource.string.count { + let response = ui.selectable_label( + self.state.string.test == index, + format!("test_{index}"), + ); + if response.clicked() { + response.surrender_focus(); + self.update(AppMessage::String(StringMessage::TestSelected(index))); + } + } + }); + }); + egui::CentralPanel::default().show_inside(ui, |ui| { + self.string_control(ui); + ui.separator(); + self.string_workspace(ui); + }); } pub(crate) fn string_update(&mut self, message: StringMessage) { @@ -102,8 +68,6 @@ impl EditorApp { StringMessage::ModeSelected(mode) => self.string_update_mode(mode), StringMessage::PointEdited(update) => self.string_update_point(update), StringMessage::WorkspaceSized(size) => self.string_update_size(size), - StringMessage::WorkspaceZoomed(zoom) => self.string_update_zoom(zoom), - StringMessage::WorkspaceDragged(drag) => self.string_update_drag(drag), } } @@ -119,7 +83,7 @@ impl EditorApp { } pub(crate) fn string_next_test(&mut self) { - let next_test = self.state.string.test + 1; + let next_test = self.state.string.test.saturating_add(1); if next_test < self.app_resource.string.count { self.string_set_test(next_test); } @@ -132,7 +96,7 @@ impl EditorApp { } } - fn string_update_size(&mut self, size: Size) { + fn string_update_size(&mut self, size: Vec2) { self.state.string.size = size; let points = &self.state.string.workspace.points; if self.state.string.workspace.camera.is_empty() && !points.is_empty() { @@ -170,7 +134,7 @@ impl StringState { solver: SolverOption::Auto, workspace: Default::default(), cameras: HashMap::with_capacity(resource.count), - size: Size::ZERO, + size: Vec2::ZERO, }; state.set_test(0, resource); @@ -196,13 +160,15 @@ impl StringState { self.cameras.insert(self.test, self.workspace.camera); let mut camera = *self.cameras.get(&index).unwrap_or(&Camera::empty()); - if camera.is_empty() && self.size.width > 0.001 { + if camera.is_empty() && self.size.x > 0.001 { let rect = IntRect::with_iter(editor_points.iter().map(|p| &p.pos)) .unwrap_or(IntRect::new(-10_000, 10_000, -10_000, 10_000)); camera = Camera::new(rect, self.size); } self.workspace.camera = camera; + self.workspace.sheet_state = Default::default(); + self.workspace.point_state = Default::default(); self.test = index; } diff --git a/examples/overlay_editor/src/app/string/control.rs b/examples/overlay_editor/src/app/string/control.rs index 35026592..2744940c 100644 --- a/examples/overlay_editor/src/app/string/control.rs +++ b/examples/overlay_editor/src/app/string/control.rs @@ -1,103 +1,8 @@ -use crate::app::fill_option::FillOption; +use crate::app::design::{controls, select}; use crate::app::main::{AppMessage, EditorApp}; -use crate::app::solver_option::SolverOption; use crate::app::string::content::StringMessage; -use iced::widget::{pick_list, Column, Container, Row, Space, Text}; -use iced::{Alignment, Length}; - -impl EditorApp { - pub(crate) fn string_control(&self) -> Column<'_, AppMessage> { - let solver_pick_list = Row::new() - .push( - Text::new("Solver:") - .width(Length::Fixed(90.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &SolverOption::ALL[..], - Some(self.state.string.solver), - on_select_solver, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - let fill_pick_list = Row::new() - .push( - Text::new("Fill Rule:") - .width(Length::Fixed(90.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &FillOption::ALL[..], - Some(self.state.string.fill), - on_select_fill, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - let mode_pick_list = Row::new() - .push( - Text::new("Mode:") - .width(Length::Fixed(90.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &ModeOption::ALL[..], - Some(self.state.string.mode), - on_select_mode, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - Column::new() - .push(solver_pick_list) - .push( - Space::new() - .width(Length::Shrink) - .height(Length::Fixed(4.0)), - ) - .push(fill_pick_list) - .push( - Space::new() - .width(Length::Shrink) - .height(Length::Fixed(4.0)), - ) - .push(mode_pick_list) - } -} - -fn on_select_fill(option: FillOption) -> AppMessage { - AppMessage::String(StringMessage::FillSelected(option)) -} - -fn on_select_mode(option: ModeOption) -> AppMessage { - AppMessage::String(StringMessage::ModeSelected(option)) -} - -fn on_select_solver(option: SolverOption) -> AppMessage { - AppMessage::String(StringMessage::SolverSelected(option)) -} +use crate::app::{fill_option::FillOption, solver_option::SolverOption}; +use eframe::egui; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(crate) enum ModeOption { @@ -134,3 +39,22 @@ impl std::fmt::Display for ModeOption { ) } } + +impl EditorApp { + pub(crate) fn string_control(&mut self, ui: &mut egui::Ui) { + controls(ui, "string_controls", |ui| { + let mut solver = self.state.string.solver; + if select(ui, "Solver", &mut solver, &SolverOption::ALL) { + self.update(AppMessage::String(StringMessage::SolverSelected(solver))); + } + let mut fill = self.state.string.fill; + if select(ui, "Fill Rule", &mut fill, &FillOption::ALL) { + self.update(AppMessage::String(StringMessage::FillSelected(fill))); + } + let mut mode = self.state.string.mode; + if select(ui, "Mode", &mut mode, &ModeOption::ALL) { + self.update(AppMessage::String(StringMessage::ModeSelected(mode))); + } + }); + } +} diff --git a/examples/overlay_editor/src/app/string/mod.rs b/examples/overlay_editor/src/app/string/mod.rs index 2ac0d54b..7c23db47 100644 --- a/examples/overlay_editor/src/app/string/mod.rs +++ b/examples/overlay_editor/src/app/string/mod.rs @@ -1,3 +1,3 @@ pub(crate) mod content; -mod control; +pub(super) mod control; mod workspace; diff --git a/examples/overlay_editor/src/app/string/workspace.rs b/examples/overlay_editor/src/app/string/workspace.rs index 977db253..e33e309b 100644 --- a/examples/overlay_editor/src/app/string/workspace.rs +++ b/examples/overlay_editor/src/app/string/workspace.rs @@ -1,4 +1,4 @@ -use crate::app::design::{style_sheet_background, Design}; +use crate::app::design::Design; use crate::app::main::{AppMessage, EditorApp}; use crate::app::string::content::StringMessage; use crate::app::string::control::ModeOption; @@ -7,13 +7,12 @@ use crate::draw::shape::ShapeWidget; use crate::draw::varicolored::VaricoloredWidget; use crate::geom::camera::Camera; use crate::point_editor::point::EditorPoint; -use crate::point_editor::widget::{PointEditUpdate, PointsEditorWidget}; +use crate::point_editor::{state::PointsEditorState, widget::PointsEditorWidget}; +use crate::sheet::state::SheetState; use crate::sheet::widget::SheetWidget; +use eframe::egui; use i_triangle::i_overlay::i_shape::int::count::IntShapes as RawIntShapes; use i_triangle::i_overlay::i_shape::int::path::IntPaths as RawIntPaths; -use iced::widget::Container; -use iced::widget::Stack; -use iced::{Length, Padding, Size, Vector}; type IntPaths = RawIntPaths; type IntShapes = RawIntShapes; @@ -26,6 +25,8 @@ pub(crate) enum Solution { pub(crate) struct WorkspaceState { pub(crate) camera: Camera, + pub(crate) sheet_state: SheetState, + pub(crate) point_state: PointsEditorState, pub(crate) body: IntPaths, pub(crate) string: IntPaths, pub(crate) solution: Solution, @@ -33,171 +34,109 @@ pub(crate) struct WorkspaceState { } impl EditorApp { - pub(crate) fn string_workspace(&self) -> Container<'_, AppMessage> { - Container::new({ - let mut stack = Stack::new(); - stack = stack.push( - Container::new(SheetWidget::new( - self.state.string.workspace.camera, - Design::negative_color().scale_alpha(0.5), - on_update_size, - on_update_zoom, - on_update_drag, - )) - .width(Length::Fill) - .height(Length::Fill), - ); - - if self.state.string.workspace.camera.is_not_empty() { - match self.state.string.mode { - ModeOption::Slice => { - if let Solution::Shapes(shapes) = &self.state.string.workspace.solution { - stack = stack.push( - Container::new(VaricoloredWidget::with_shapes( - shapes, - self.state.string.workspace.camera, - 2.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ); - } - stack = stack.push( - Container::new(PathWidget::with_paths( - &self.state.string.workspace.string, - self.state.string.workspace.camera, - Design::negative_color(), - 2.0, - true, - )) - .width(Length::Fill) - .height(Length::Fill), - ) - } - ModeOption::ClipDirect | ModeOption::ClipInvert => { - stack = stack - .push( - Container::new(ShapeWidget::with_paths( - &self.state.string.workspace.body, - self.state.string.workspace.camera, - Some(self.state.string.fill.fill_rule()), - Some(Design::clip_color().scale_alpha(0.3)), - Some(Design::clip_color()), - 2.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ) - .push( - Container::new(PathWidget::with_paths( - &self.state.string.workspace.string, - self.state.string.workspace.camera, - Design::negative_color(), - 2.0, - true, - )) - .width(Length::Fill) - .height(Length::Fill), - ); - if let Solution::Paths(paths) = &self.state.string.workspace.solution { - stack = stack.push( - Container::new(PathWidget::with_paths( - paths, - self.state.string.workspace.camera, - Design::subject_color(), - 2.0, - true, - )) - .width(Length::Fill) - .height(Length::Fill), - ); - } - } - _ => { - stack = stack - .push( - Container::new(ShapeWidget::with_paths( - &self.state.string.workspace.body, - self.state.string.workspace.camera, - Some(self.state.string.fill.fill_rule()), - Some(Design::subject_color().scale_alpha(0.2)), - Some(Design::subject_color()), - 2.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ) - .push( - Container::new(PathWidget::with_paths( - &self.state.string.workspace.string, - self.state.string.workspace.camera, - Design::negative_color(), - 2.0, - true, - )) - .width(Length::Fill) - .height(Length::Fill), - ) + pub(crate) fn string_workspace(&mut self, ui: &mut egui::Ui) { + self.update(AppMessage::String(StringMessage::WorkspaceSized( + ui.available_size(), + ))); + let workspace = &mut self.state.string.workspace; + let (painter, update) = SheetWidget::show( + ui, + &mut workspace.camera, + &workspace.points, + &mut workspace.sheet_state, + &mut workspace.point_state, + ); + if let Some(update) = update { + self.update(AppMessage::String(StringMessage::PointEdited(update))); + ui.ctx().request_repaint(); + } + let workspace = &self.state.string.workspace; + if workspace.camera.is_not_empty() { + match self.state.string.mode { + ModeOption::Slice => { + if let Solution::Shapes(shapes) = &workspace.solution { + VaricoloredWidget::with_shapes(shapes, workspace.camera, 2.0) + .paint(&painter); } + PathWidget::with_paths( + &workspace.string, + workspace.camera, + Design::negative_color(), + 2.0, + true, + ) + .paint(&painter); } - stack = stack.push( - Container::new( - PointsEditorWidget::new( - &self.state.string.workspace.points, - self.state.string.workspace.camera, - on_update_point, + ModeOption::ClipDirect | ModeOption::ClipInvert => { + ShapeWidget::with_paths( + &workspace.body, + workspace.camera, + Some(self.state.string.fill.fill_rule()), + Some(Design::clip_color().gamma_multiply(0.3)), + Some(Design::clip_color()), + 2.0, + ) + .paint(&painter); + PathWidget::with_paths( + &workspace.string, + workspace.camera, + Design::negative_color(), + 2.0, + true, + ) + .paint(&painter); + if let Solution::Paths(paths) = &workspace.solution { + PathWidget::with_paths( + paths, + workspace.camera, + Design::subject_color(), + 2.0, + true, ) - .set_drag_color(Design::accent_color()) - .set_hover_color(Design::negative_color()), + .paint(&painter); + } + } + _ => { + ShapeWidget::with_paths( + &workspace.body, + workspace.camera, + Some(self.state.string.fill.fill_rule()), + Some(Design::subject_color().gamma_multiply(0.2)), + Some(Design::subject_color()), + 2.0, + ) + .paint(&painter); + PathWidget::with_paths( + &workspace.string, + workspace.camera, + Design::negative_color(), + 2.0, + true, ) - .width(Length::Fill) - .height(Length::Fill), - ); + .paint(&painter); + } } - - stack.push( - Container::new(self.string_control()) - .width(Length::Shrink) - .height(Length::Shrink) - .padding(Padding::new(8.0)), - ) - }) - .style(style_sheet_background) + } + PointsEditorWidget::paint( + &painter, + workspace.camera, + &workspace.points, + &workspace.point_state, + ); } - - pub(super) fn string_update_point(&mut self, update: PointEditUpdate) { + pub(super) fn string_update_point( + &mut self, + update: crate::point_editor::widget::PointEditUpdate, + ) { self.state.string.string_update_point(update); } - - pub(super) fn string_update_zoom(&mut self, camera: Camera) { - self.state.string.workspace.camera = camera; - } - - pub(super) fn string_update_drag(&mut self, new_pos: Vector) { - self.state.string.workspace.camera.pos = new_pos; - } -} - -fn on_update_point(event: PointEditUpdate) -> AppMessage { - AppMessage::String(StringMessage::PointEdited(event)) } - -fn on_update_size(size: Size) -> AppMessage { - AppMessage::String(StringMessage::WorkspaceSized(size)) -} - -fn on_update_zoom(zoom: Camera) -> AppMessage { - AppMessage::String(StringMessage::WorkspaceZoomed(zoom)) -} - -fn on_update_drag(drag: Vector) -> AppMessage { - AppMessage::String(StringMessage::WorkspaceDragged(drag)) -} - impl Default for WorkspaceState { fn default() -> Self { WorkspaceState { camera: Camera::empty(), + sheet_state: Default::default(), + point_state: Default::default(), body: vec![], string: vec![], solution: Solution::None, diff --git a/examples/overlay_editor/src/app/stroke/content.rs b/examples/overlay_editor/src/app/stroke/content.rs index 90472ab4..86afe93f 100644 --- a/examples/overlay_editor/src/app/stroke/content.rs +++ b/examples/overlay_editor/src/app/stroke/content.rs @@ -1,4 +1,3 @@ -use crate::app::design; use crate::app::main::{AppMessage, EditorApp}; use crate::app::stroke::control::{CapOption, JoinOption}; use crate::app::stroke::workspace::WorkspaceState; @@ -6,12 +5,11 @@ use crate::data::stroke::StrokeResource; use crate::geom::camera::Camera; use crate::point_editor::point::PathsToEditorPoints; use crate::point_editor::widget::PointEditUpdate; +use eframe::egui::{self, Vec2}; use i_triangle::i_overlay::i_float::int::point::IntPoint; use i_triangle::i_overlay::i_float::int::rect::IntRect; use i_triangle::i_overlay::mesh::float::stroke::offset::StrokeOffset; use i_triangle::i_overlay::mesh::float::style::{LineCap, LineJoin, StrokeStyle}; -use iced::widget::{scrollable, Button, Column, Container, Row, Space, Text}; -use iced::{Alignment, Length, Padding, Size, Vector}; use std::collections::HashMap; use std::rc::Rc; @@ -26,7 +24,7 @@ pub(crate) struct StrokeState { pub(crate) join: JoinOption, pub(crate) join_value: u8, pub(crate) workspace: WorkspaceState, - pub(crate) size: Size, + pub(crate) size: Vec2, pub(crate) cameras: HashMap, } @@ -42,64 +40,33 @@ pub(crate) enum StrokeMessage { JoinSelected(JoinOption), JoinValueUpdated(u8), PointEdited(PointEditUpdate), - WorkspaceSized(Size), - WorkspaceZoomed(Camera), - WorkspaceDragged(Vector), + WorkspaceSized(Vec2), } impl EditorApp { - fn stroke_sidebar(&self) -> Column<'_, AppMessage> { - let count = self.app_resource.stroke.count; - let mut column = - Column::new().push(Space::new().width(Length::Fill).height(Length::Fixed(2.0))); - for index in 0..count { - let is_selected = self.state.stroke.test == index; - column = column.push( - Container::new( - Button::new( - Text::new(format!("test_{}", index)) - .style(if is_selected { - design::style_sidebar_text_selected - } else { - design::style_sidebar_text - }) - .size(14), - ) - .width(Length::Fill) - .on_press(AppMessage::Stroke(StrokeMessage::TestSelected(index))) - .style(if is_selected { - design::style_sidebar_button_selected - } else { - design::style_sidebar_button - }), - ) - .padding(self.design.action_padding()), - ); - } - - column - } - - pub(crate) fn stroke_content(&self) -> Row<'_, AppMessage> { - Row::new() - .push( - scrollable( - Container::new(self.stroke_sidebar()) - .width(Length::Fixed(160.0)) - .height(Length::Shrink) - .align_x(Alignment::Start) - .padding(Padding::new(0.0).right(8)) - .style(design::style_sidebar_background), - ) - .direction(scrollable::Direction::Vertical( - scrollable::Scrollbar::new() - .width(4) - .margin(0) - .scroller_width(4) - .anchor(scrollable::Anchor::Start), - )), - ) - .push(self.stroke_workspace()) + pub(crate) fn stroke_content(&mut self, ui: &mut egui::Ui) { + egui::Panel::left("stroke_tests") + .exact_size(150.0) + .resizable(false) + .show_inside(ui, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + for index in 0..self.app_resource.stroke.count { + let response = ui.selectable_label( + self.state.stroke.test == index, + format!("test_{index}"), + ); + if response.clicked() { + response.surrender_focus(); + self.update(AppMessage::Stroke(StrokeMessage::TestSelected(index))); + } + } + }); + }); + egui::CentralPanel::default().show_inside(ui, |ui| { + self.stroke_control(ui); + ui.separator(); + self.stroke_workspace(ui); + }); } pub(crate) fn stroke_update(&mut self, message: StrokeMessage) { @@ -115,8 +82,6 @@ impl EditorApp { StrokeMessage::JoinValueUpdated(value) => self.stroke_update_join_value(value), StrokeMessage::PointEdited(update) => self.stroke_update_point(update), StrokeMessage::WorkspaceSized(size) => self.stroke_update_size(size), - StrokeMessage::WorkspaceZoomed(zoom) => self.stroke_update_zoom(zoom), - StrokeMessage::WorkspaceDragged(drag) => self.stroke_update_drag(drag), } } @@ -132,7 +97,7 @@ impl EditorApp { } pub(crate) fn stroke_next_test(&mut self) { - let next_test = self.state.stroke.test + 1; + let next_test = self.state.stroke.test.saturating_add(1); if next_test < self.app_resource.stroke.count { self.stroke_set_test(next_test); } @@ -145,7 +110,7 @@ impl EditorApp { } } - fn stroke_update_size(&mut self, size: Size) { + fn stroke_update_size(&mut self, size: Vec2) { self.state.stroke.size = size; let points = &self.state.stroke.workspace.points; if self.state.stroke.workspace.camera.is_empty() && !points.is_empty() { @@ -213,7 +178,7 @@ impl StrokeState { join_value: 50, workspace: Default::default(), cameras: HashMap::with_capacity(resource.count), - size: Size::ZERO, + size: Vec2::ZERO, }; state.set_test(0, resource); @@ -250,13 +215,15 @@ impl StrokeState { self.cameras.insert(self.test, self.workspace.camera); let mut camera = *self.cameras.get(&index).unwrap_or(&Camera::empty()); - if camera.is_empty() && self.size.width > 0.001 { + if camera.is_empty() && self.size.x > 0.001 { let rect = IntRect::with_iter(editor_points.iter().map(|p| &p.pos)) .unwrap_or(IntRect::new(-10_000, 10_000, -10_000, 10_000)); camera = Camera::new(rect, self.size); } self.workspace.camera = camera; + self.workspace.sheet_state = Default::default(); + self.workspace.point_state = Default::default(); self.test = index; } diff --git a/examples/overlay_editor/src/app/stroke/control.rs b/examples/overlay_editor/src/app/stroke/control.rs index db5b86b3..c27d70b3 100644 --- a/examples/overlay_editor/src/app/stroke/control.rs +++ b/examples/overlay_editor/src/app/stroke/control.rs @@ -1,7 +1,7 @@ +use crate::app::design::{checkbox, controls, select, slider}; use crate::app::main::{AppMessage, EditorApp}; use crate::app::stroke::content::StrokeMessage; -use iced::widget::{checkbox, pick_list, slider, Column, Container, Row, Space, Text}; -use iced::{Alignment, Length, Padding}; +use eframe::egui; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub(crate) enum CapOption { @@ -63,189 +63,72 @@ impl std::fmt::Display for JoinOption { } impl EditorApp { - pub(crate) fn stroke_control(&self) -> Column<'_, AppMessage> { - let width_list = Row::new() - .push( - Text::new("Stroke Width:") - .width(Length::Fixed(120.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - slider(0.1f32..=10.0f32, self.state.stroke.width, on_update_width) - .step(0.01f32), - ) - .width(160) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - let mut start_cap_pick_list = Row::new() - .push( - Text::new("Start Cap:") - .width(Length::Fixed(120.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &CapOption::ALL[..], - Some(self.state.stroke.start_cap), - on_select_start_cap, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - if self.state.stroke.start_cap == CapOption::Round { - let slider = slider( - 1..=100, - self.state.stroke.start_cap_value, - on_update_start_cap_value, - ) - .default(50) - .shift_step(5); - - start_cap_pick_list = start_cap_pick_list.push( - Container::new(slider) - .padding(Padding::new(0.0).left(20.0)) - .width(250) - .height(Length::Fill) - .align_y(Alignment::Center), - ); - } - - let mut end_cap_pick_list = Row::new() - .push( - Text::new("End Cap:") - .width(Length::Fixed(120.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &CapOption::ALL[..], - Some(self.state.stroke.end_cap), - on_select_end_cap, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - if self.state.stroke.end_cap == CapOption::Round { - let slider = slider( - 1..=100, - self.state.stroke.end_cap_value, - on_update_end_cap_value, - ) - .default(50) - .shift_step(5); - - end_cap_pick_list = end_cap_pick_list.push( - Container::new(slider) - .padding(Padding::new(0.0).left(20.0)) - .width(250) - .height(Length::Fill) - .align_y(Alignment::Center), - ); - } - - let mut join_pick_list = Row::new() - .push( - Text::new("Line Join:") - .width(Length::Fixed(120.0)) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .push( - Container::new( - pick_list( - &JoinOption::ALL[..], - Some(self.state.stroke.join), - on_select_join, - ) - .width(Length::Fixed(160.0)), - ) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - if self.state.stroke.join != JoinOption::Bevel { - let slider = slider(1..=100, self.state.stroke.join_value, on_update_join_value) - .default(50) - .shift_step(5); - - join_pick_list = join_pick_list.push( - Container::new(slider) - .padding(Padding::new(0.0).left(20.0)) - .width(250) - .height(Length::Fill) - .align_y(Alignment::Center), - ); - } - - Column::new() - .push(width_list) - .push(start_cap_pick_list) - .push( - Space::new() - .width(Length::Shrink) - .height(Length::Fixed(4.0)), - ) - .push(end_cap_pick_list) - .push( - Space::new() - .width(Length::Shrink) - .height(Length::Fixed(4.0)), - ) - .push(join_pick_list) - .push( - checkbox(self.state.stroke.is_closed) - .label("Is Closed") - .on_toggle(on_set_is_closed), - ) + pub(crate) fn stroke_control(&mut self, ui: &mut egui::Ui) { + controls(ui, "stroke_controls", |ui| { + let mut width = self.state.stroke.width; + if slider( + ui, + "Stroke Width", + egui::Slider::new(&mut width, 0.1..=10.0).step_by(0.01), + ) { + self.update(AppMessage::Stroke(StrokeMessage::WidthValueUpdated(width))); + } + let mut start_cap = self.state.stroke.start_cap; + if select(ui, "Start Cap", &mut start_cap, &CapOption::ALL) { + self.update(AppMessage::Stroke(StrokeMessage::StartCapSelected( + start_cap, + ))); + } + if self.state.stroke.start_cap == CapOption::Round { + let mut start_cap_value = self.state.stroke.start_cap_value; + if slider( + ui, + "Start Cap Detail", + egui::Slider::new(&mut start_cap_value, 1..=100), + ) { + self.update(AppMessage::Stroke(StrokeMessage::StartCapValueUpdated( + start_cap_value, + ))); + } + } + let mut end_cap = self.state.stroke.end_cap; + if select(ui, "End Cap", &mut end_cap, &CapOption::ALL) { + self.update(AppMessage::Stroke(StrokeMessage::EndCapSelected(end_cap))); + } + if self.state.stroke.end_cap == CapOption::Round { + let mut end_cap_value = self.state.stroke.end_cap_value; + if slider( + ui, + "End Cap Detail", + egui::Slider::new(&mut end_cap_value, 1..=100), + ) { + self.update(AppMessage::Stroke(StrokeMessage::EndCapValueUpdated( + end_cap_value, + ))); + } + } + let mut join = self.state.stroke.join; + if select(ui, "Line Join", &mut join, &JoinOption::ALL) { + self.update(AppMessage::Stroke(StrokeMessage::JoinSelected(join))); + } + if self.state.stroke.join != JoinOption::Bevel { + let mut join_value = self.state.stroke.join_value; + if slider( + ui, + "Join Detail", + egui::Slider::new(&mut join_value, 1..=100), + ) { + self.update(AppMessage::Stroke(StrokeMessage::JoinValueUpdated( + join_value, + ))); + } + } + let mut is_closed = self.state.stroke.is_closed; + if checkbox(ui, "Is Closed", &mut is_closed) { + self.update(AppMessage::Stroke(StrokeMessage::IsClosedUpdated( + is_closed, + ))); + } + }); } } - -fn on_update_width(value: f32) -> AppMessage { - AppMessage::Stroke(StrokeMessage::WidthValueUpdated(value)) -} - -fn on_set_is_closed(value: bool) -> AppMessage { - AppMessage::Stroke(StrokeMessage::IsClosedUpdated(value)) -} - -fn on_select_start_cap(option: CapOption) -> AppMessage { - AppMessage::Stroke(StrokeMessage::StartCapSelected(option)) -} - -fn on_update_start_cap_value(value: u8) -> AppMessage { - AppMessage::Stroke(StrokeMessage::StartCapValueUpdated(value)) -} - -fn on_select_end_cap(option: CapOption) -> AppMessage { - AppMessage::Stroke(StrokeMessage::EndCapSelected(option)) -} - -fn on_update_end_cap_value(value: u8) -> AppMessage { - AppMessage::Stroke(StrokeMessage::EndCapValueUpdated(value)) -} - -fn on_select_join(option: JoinOption) -> AppMessage { - AppMessage::Stroke(StrokeMessage::JoinSelected(option)) -} - -fn on_update_join_value(value: u8) -> AppMessage { - AppMessage::Stroke(StrokeMessage::JoinValueUpdated(value)) -} diff --git a/examples/overlay_editor/src/app/stroke/workspace.rs b/examples/overlay_editor/src/app/stroke/workspace.rs index 2abedd8b..8a7e962f 100644 --- a/examples/overlay_editor/src/app/stroke/workspace.rs +++ b/examples/overlay_editor/src/app/stroke/workspace.rs @@ -1,22 +1,23 @@ -use crate::app::design::{style_sheet_background, Design}; +use crate::app::design::Design; use crate::app::main::{AppMessage, EditorApp}; use crate::app::stroke::content::StrokeMessage; use crate::draw::path::PathWidget; use crate::draw::shape::ShapeWidget; use crate::geom::camera::Camera; use crate::point_editor::point::EditorPoint; -use crate::point_editor::widget::{PointEditUpdate, PointsEditorWidget}; +use crate::point_editor::{state::PointsEditorState, widget::PointsEditorWidget}; +use crate::sheet::state::SheetState; use crate::sheet::widget::SheetWidget; +use eframe::egui; use i_triangle::i_overlay::core::fill_rule::FillRule; use i_triangle::i_overlay::i_shape::int::path::IntPaths as RawIntPaths; -use iced::widget::Container; -use iced::widget::Stack; -use iced::{Length, Padding, Size, Vector}; type IntPaths = RawIntPaths; pub(crate) struct WorkspaceState { pub(crate) camera: Camera, + pub(crate) sheet_state: SheetState, + pub(crate) point_state: PointsEditorState, pub(crate) scale: f32, pub(crate) stroke_input: IntPaths, pub(crate) stroke_output: IntPaths, @@ -24,107 +25,66 @@ pub(crate) struct WorkspaceState { } impl EditorApp { - pub(crate) fn stroke_workspace(&self) -> Container<'_, AppMessage> { - Container::new({ - let mut stack = Stack::new(); - stack = stack.push( - Container::new(SheetWidget::new( - self.state.stroke.workspace.camera, - Design::negative_color().scale_alpha(0.5), - on_update_size, - on_update_zoom, - on_update_drag, - )) - .width(Length::Fill) - .height(Length::Fill), - ); - - if self.state.stroke.workspace.camera.is_not_empty() { - let shapes = &self.state.stroke.workspace.stroke_output; - if !shapes.is_empty() { - stack = stack.push( - Container::new(ShapeWidget::with_paths( - &self.state.stroke.workspace.stroke_output, - self.state.stroke.workspace.camera, - Some(FillRule::NonZero), - Some(Design::solution_color().scale_alpha(0.1)), - Some(Design::solution_color()), - 2.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ); - } - stack = stack.push( - Container::new(PathWidget::with_paths( - &self.state.stroke.workspace.stroke_input, - self.state.stroke.workspace.camera, - Design::subject_color(), - 1.0, - false, - )) - .width(Length::Fill) - .height(Length::Fill), - ); - stack = stack.push( - Container::new( - PointsEditorWidget::new( - &self.state.stroke.workspace.points, - self.state.stroke.workspace.camera, - on_update_point, - ) - .set_drag_color(Design::accent_color()) - .set_hover_color(Design::negative_color()), - ) - .width(Length::Fill) - .height(Length::Fill), - ); + pub(crate) fn stroke_workspace(&mut self, ui: &mut egui::Ui) { + self.update(AppMessage::Stroke(StrokeMessage::WorkspaceSized( + ui.available_size(), + ))); + let workspace = &mut self.state.stroke.workspace; + let (painter, update) = SheetWidget::show( + ui, + &mut workspace.camera, + &workspace.points, + &mut workspace.sheet_state, + &mut workspace.point_state, + ); + if let Some(update) = update { + self.update(AppMessage::Stroke(StrokeMessage::PointEdited(update))); + ui.ctx().request_repaint(); + } + let workspace = &self.state.stroke.workspace; + if workspace.camera.is_not_empty() { + let shapes = &workspace.stroke_output; + if !shapes.is_empty() { + ShapeWidget::with_paths( + &workspace.stroke_output, + workspace.camera, + Some(FillRule::NonZero), + Some(Design::solution_color().gamma_multiply(0.1)), + Some(Design::solution_color()), + 2.0, + ) + .paint(&painter); } - - stack.push( - Container::new(self.stroke_control()) - .width(Length::Shrink) - .height(Length::Shrink) - .padding(Padding::new(8.0)), + PathWidget::with_paths( + &workspace.stroke_input, + workspace.camera, + Design::subject_color(), + 1.0, + false, ) - }) - .style(style_sheet_background) + .paint(&painter); + } + PointsEditorWidget::paint( + &painter, + workspace.camera, + &workspace.points, + &workspace.point_state, + ); } - - pub(super) fn stroke_update_point(&mut self, update: PointEditUpdate) { + pub(super) fn stroke_update_point( + &mut self, + update: crate::point_editor::widget::PointEditUpdate, + ) { self.state.stroke.stroke_update_point(update); } - - pub(super) fn stroke_update_zoom(&mut self, camera: Camera) { - self.state.stroke.workspace.camera = camera; - } - - pub(super) fn stroke_update_drag(&mut self, new_pos: Vector) { - self.state.stroke.workspace.camera.pos = new_pos; - } } - -fn on_update_point(event: PointEditUpdate) -> AppMessage { - AppMessage::Stroke(StrokeMessage::PointEdited(event)) -} - -fn on_update_size(size: Size) -> AppMessage { - AppMessage::Stroke(StrokeMessage::WorkspaceSized(size)) -} - -fn on_update_zoom(zoom: Camera) -> AppMessage { - AppMessage::Stroke(StrokeMessage::WorkspaceZoomed(zoom)) -} - -fn on_update_drag(drag: Vector) -> AppMessage { - AppMessage::Stroke(StrokeMessage::WorkspaceDragged(drag)) -} - impl Default for WorkspaceState { fn default() -> Self { WorkspaceState { scale: 1.0, camera: Camera::empty(), + sheet_state: Default::default(), + point_state: Default::default(), stroke_input: vec![], stroke_output: vec![], points: vec![], diff --git a/examples/overlay_editor/src/app/variable_stroke/content.rs b/examples/overlay_editor/src/app/variable_stroke/content.rs index b921de09..0b138f9e 100644 --- a/examples/overlay_editor/src/app/variable_stroke/content.rs +++ b/examples/overlay_editor/src/app/variable_stroke/content.rs @@ -1,16 +1,14 @@ -use crate::app::design; use crate::app::main::{AppMessage, EditorApp}; use crate::app::variable_stroke::workspace::WorkspaceState; use crate::data::variable_stroke::VariableStrokeResource; use crate::geom::camera::Camera; use crate::point_editor::point::PathsToEditorPoints; use crate::point_editor::widget::PointEditUpdate; +use eframe::egui::{self, Vec2}; use i_triangle::i_overlay::i_float::int::point::IntPoint; use i_triangle::i_overlay::i_float::int::rect::IntRect; use i_triangle::i_overlay::mesh::float::variable_stroke::offset::VariableStrokeOffset; use i_triangle::i_overlay::mesh::float::variable_stroke::{StrokeVertex, VariableStrokeStyle}; -use iced::widget::{scrollable, Button, Column, Container, Row, Space, Text}; -use iced::{Alignment, Length, Padding, Size, Vector}; use std::collections::HashMap; use std::fmt::Write; @@ -25,7 +23,7 @@ pub(crate) struct VariableStrokeState { pub(crate) width_scale: f32, pub(crate) round_angle: u8, pub(crate) workspace: WorkspaceState, - pub(crate) size: Size, + pub(crate) size: Vec2, pub(crate) cameras: HashMap, } @@ -35,66 +33,35 @@ pub(crate) enum VariableStrokeMessage { WidthScaleUpdated(f32), RoundAngleUpdated(u8), PointEdited(PointEditUpdate), - WorkspaceSized(Size), - WorkspaceZoomed(Camera), - WorkspaceDragged(Vector), + WorkspaceSized(Vec2), } impl EditorApp { - fn variable_stroke_sidebar(&self) -> Column<'_, AppMessage> { - let count = self.app_resource.variable_stroke.count; - let mut column = - Column::new().push(Space::new().width(Length::Fill).height(Length::Fixed(2.0))); - for index in 0..count { - let is_selected = self.state.variable_stroke.test == index; - column = column.push( - Container::new( - Button::new( - Text::new(format!("test_{}", index)) - .style(if is_selected { - design::style_sidebar_text_selected - } else { - design::style_sidebar_text - }) - .size(14), - ) - .width(Length::Fill) - .on_press(AppMessage::VariableStroke( - VariableStrokeMessage::TestSelected(index), - )) - .style(if is_selected { - design::style_sidebar_button_selected - } else { - design::style_sidebar_button - }), - ) - .padding(self.design.action_padding()), - ); - } - - column - } - - pub(crate) fn variable_stroke_content(&self) -> Row<'_, AppMessage> { - Row::new() - .push( - scrollable( - Container::new(self.variable_stroke_sidebar()) - .width(Length::Fixed(180.0)) - .height(Length::Shrink) - .align_x(Alignment::Start) - .padding(Padding::new(0.0).right(8)) - .style(design::style_sidebar_background), - ) - .direction(scrollable::Direction::Vertical( - scrollable::Scrollbar::new() - .width(4) - .margin(0) - .scroller_width(4) - .anchor(scrollable::Anchor::Start), - )), - ) - .push(self.variable_stroke_workspace()) + pub(crate) fn variable_stroke_content(&mut self, ui: &mut egui::Ui) { + egui::Panel::left("variable_stroke_tests") + .exact_size(150.0) + .resizable(false) + .show_inside(ui, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + for index in 0..self.app_resource.variable_stroke.count { + let response = ui.selectable_label( + self.state.variable_stroke.test == index, + format!("test_{index}"), + ); + if response.clicked() { + response.surrender_focus(); + self.update(AppMessage::VariableStroke( + VariableStrokeMessage::TestSelected(index), + )); + } + } + }); + }); + egui::CentralPanel::default().show_inside(ui, |ui| { + self.variable_stroke_control(ui); + ui.separator(); + self.variable_stroke_workspace(ui); + }); } pub(crate) fn variable_stroke_update(&mut self, message: VariableStrokeMessage) { @@ -108,8 +75,6 @@ impl EditorApp { } VariableStrokeMessage::PointEdited(update) => self.variable_stroke_update_point(update), VariableStrokeMessage::WorkspaceSized(size) => self.variable_stroke_update_size(size), - VariableStrokeMessage::WorkspaceZoomed(zoom) => self.variable_stroke_update_zoom(zoom), - VariableStrokeMessage::WorkspaceDragged(drag) => self.variable_stroke_update_drag(drag), } } @@ -125,7 +90,7 @@ impl EditorApp { } pub(crate) fn variable_stroke_next_test(&mut self) { - let next_test = self.state.variable_stroke.test + 1; + let next_test = self.state.variable_stroke.test.saturating_add(1); if next_test < self.app_resource.variable_stroke.count { self.variable_stroke_set_test(next_test); } @@ -138,7 +103,7 @@ impl EditorApp { } } - fn variable_stroke_update_size(&mut self, size: Size) { + fn variable_stroke_update_size(&mut self, size: Vec2) { self.state.variable_stroke.size = size; let points = &self.state.variable_stroke.workspace.points; if self.state.variable_stroke.workspace.camera.is_empty() && !points.is_empty() { @@ -170,7 +135,7 @@ impl VariableStrokeState { round_angle: 12, workspace: Default::default(), cameras: HashMap::with_capacity(resource.count), - size: Size::ZERO, + size: Vec2::ZERO, }; state.set_test(0, resource); @@ -215,13 +180,15 @@ impl VariableStrokeState { .feed_edit_points(0, editor_points); let mut camera = *self.cameras.get(&index).unwrap_or(&Camera::empty()); - if camera.is_empty() && self.size.width > 0.001 { + if camera.is_empty() && self.size.x > 0.001 { let rect = IntRect::with_iter(editor_points.iter().map(|p| &p.pos)) .unwrap_or(IntRect::new(-10_000, 10_000, -10_000, 10_000)); camera = Camera::new(rect, self.size); } self.workspace.camera = camera; + self.workspace.sheet_state = Default::default(); + self.workspace.point_state = Default::default(); self.test = index; } diff --git a/examples/overlay_editor/src/app/variable_stroke/control.rs b/examples/overlay_editor/src/app/variable_stroke/control.rs index 43481197..9453f185 100644 --- a/examples/overlay_editor/src/app/variable_stroke/control.rs +++ b/examples/overlay_editor/src/app/variable_stroke/control.rs @@ -1,60 +1,31 @@ +use crate::app::design::{controls, slider}; use crate::app::main::{AppMessage, EditorApp}; use crate::app::variable_stroke::content::VariableStrokeMessage; -use iced::widget::{slider, Column, Container, Row, Text}; -use iced::{Alignment, Length}; +use eframe::egui; impl EditorApp { - pub(crate) fn variable_stroke_control(&self) -> Column<'_, AppMessage> { - let width_scale = Row::new() - .push(label("Width Scale:")) - .push( - Container::new( - slider( - 0.1f32..=3.0f32, - self.state.variable_stroke.width_scale, - on_update_width_scale, - ) - .step(0.01_f32), - ) - .width(160) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - let round_angle = Row::new() - .push(label("Round Detail:")) - .push( - Container::new( - slider( - 1..=50, - self.state.variable_stroke.round_angle, - on_update_round_angle, - ) - .default(12) - .shift_step(5), - ) - .width(160) - .height(Length::Fill) - .align_y(Alignment::Center), - ) - .height(Length::Fixed(40.0)); - - Column::new().push(width_scale).push(round_angle) + pub(crate) fn variable_stroke_control(&mut self, ui: &mut egui::Ui) { + controls(ui, "variable_stroke_controls", |ui| { + let mut width_scale = self.state.variable_stroke.width_scale; + if slider( + ui, + "Width Scale", + egui::Slider::new(&mut width_scale, 0.1..=3.0).step_by(0.01), + ) { + self.update(AppMessage::VariableStroke( + VariableStrokeMessage::WidthScaleUpdated(width_scale), + )); + } + let mut round_angle = self.state.variable_stroke.round_angle; + if slider( + ui, + "Round Detail", + egui::Slider::new(&mut round_angle, 1..=50), + ) { + self.update(AppMessage::VariableStroke( + VariableStrokeMessage::RoundAngleUpdated(round_angle), + )); + } + }); } } - -fn label(value: &str) -> Text<'_> { - Text::new(value) - .width(Length::Fixed(120.0)) - .height(Length::Fill) - .align_y(Alignment::Center) -} - -fn on_update_width_scale(value: f32) -> AppMessage { - AppMessage::VariableStroke(VariableStrokeMessage::WidthScaleUpdated(value)) -} - -fn on_update_round_angle(value: u8) -> AppMessage { - AppMessage::VariableStroke(VariableStrokeMessage::RoundAngleUpdated(value)) -} diff --git a/examples/overlay_editor/src/app/variable_stroke/workspace.rs b/examples/overlay_editor/src/app/variable_stroke/workspace.rs index 6335f44c..123e2365 100644 --- a/examples/overlay_editor/src/app/variable_stroke/workspace.rs +++ b/examples/overlay_editor/src/app/variable_stroke/workspace.rs @@ -1,20 +1,21 @@ -use crate::app::design::{style_sheet_background, Design}; +use crate::app::design::Design; use crate::app::main::{AppMessage, EditorApp}; use crate::app::variable_stroke::content::{VariableStrokeMessage, VariableStrokePoint}; use crate::draw::path::PathWidget; use crate::draw::shape::ShapeWidget; use crate::geom::camera::Camera; use crate::point_editor::point::EditorPoint; -use crate::point_editor::widget::{PointEditUpdate, PointsEditorWidget}; +use crate::point_editor::{state::PointsEditorState, widget::PointsEditorWidget}; +use crate::sheet::state::SheetState; use crate::sheet::widget::SheetWidget; +use eframe::egui; use i_triangle::i_overlay::core::fill_rule::FillRule; use i_triangle::i_overlay::i_shape::int::path::IntPaths; -use iced::widget::Container; -use iced::widget::Stack; -use iced::{Length, Padding, Size, Vector}; pub(crate) struct WorkspaceState { pub(crate) camera: Camera, + pub(crate) sheet_state: SheetState, + pub(crate) point_state: PointsEditorState, pub(crate) scale: f32, pub(crate) variable_input: Vec>, pub(crate) centerline_input: IntPaths, @@ -23,109 +24,70 @@ pub(crate) struct WorkspaceState { } impl EditorApp { - pub(crate) fn variable_stroke_workspace(&self) -> Container<'_, AppMessage> { - Container::new({ - let mut stack = Stack::new(); - stack = stack.push( - Container::new(SheetWidget::new( - self.state.variable_stroke.workspace.camera, - Design::negative_color().scale_alpha(0.5), - on_update_size, - on_update_zoom, - on_update_drag, - )) - .width(Length::Fill) - .height(Length::Fill), - ); - - if self.state.variable_stroke.workspace.camera.is_not_empty() { - let shapes = &self.state.variable_stroke.workspace.stroke_output; - if !shapes.is_empty() { - stack = stack.push( - Container::new(ShapeWidget::with_paths( - &self.state.variable_stroke.workspace.stroke_output, - self.state.variable_stroke.workspace.camera, - Some(FillRule::NonZero), - Some(Design::solution_color().scale_alpha(0.1)), - Some(Design::solution_color()), - 2.0, - )) - .width(Length::Fill) - .height(Length::Fill), - ); - } - stack = stack.push( - Container::new(PathWidget::with_paths( - &self.state.variable_stroke.workspace.centerline_input, - self.state.variable_stroke.workspace.camera, - Design::subject_color(), - 1.0, - false, - )) - .width(Length::Fill) - .height(Length::Fill), - ); - stack = stack.push( - Container::new( - PointsEditorWidget::new( - &self.state.variable_stroke.workspace.points, - self.state.variable_stroke.workspace.camera, - on_update_point, - ) - .set_drag_color(Design::accent_color()) - .set_hover_color(Design::negative_color()), - ) - .width(Length::Fill) - .height(Length::Fill), - ); + pub(crate) fn variable_stroke_workspace(&mut self, ui: &mut egui::Ui) { + self.update(AppMessage::VariableStroke( + VariableStrokeMessage::WorkspaceSized(ui.available_size()), + )); + let workspace = &mut self.state.variable_stroke.workspace; + let (painter, update) = SheetWidget::show( + ui, + &mut workspace.camera, + &workspace.points, + &mut workspace.sheet_state, + &mut workspace.point_state, + ); + if let Some(update) = update { + self.update(AppMessage::VariableStroke( + VariableStrokeMessage::PointEdited(update), + )); + ui.ctx().request_repaint(); + } + let workspace = &self.state.variable_stroke.workspace; + if workspace.camera.is_not_empty() { + let shapes = &workspace.stroke_output; + if !shapes.is_empty() { + ShapeWidget::with_paths( + &workspace.stroke_output, + workspace.camera, + Some(FillRule::NonZero), + Some(Design::solution_color().gamma_multiply(0.1)), + Some(Design::solution_color()), + 2.0, + ) + .paint(&painter); } - - stack.push( - Container::new(self.variable_stroke_control()) - .width(Length::Shrink) - .height(Length::Shrink) - .padding(Padding::new(8.0)), + PathWidget::with_paths( + &workspace.centerline_input, + workspace.camera, + Design::subject_color(), + 1.0, + false, ) - }) - .style(style_sheet_background) + .paint(&painter); + } + PointsEditorWidget::paint( + &painter, + workspace.camera, + &workspace.points, + &workspace.point_state, + ); } - - pub(super) fn variable_stroke_update_point(&mut self, update: PointEditUpdate) { + pub(super) fn variable_stroke_update_point( + &mut self, + update: crate::point_editor::widget::PointEditUpdate, + ) { self.state .variable_stroke .variable_stroke_update_point(update); } - - pub(super) fn variable_stroke_update_zoom(&mut self, camera: Camera) { - self.state.variable_stroke.workspace.camera = camera; - } - - pub(super) fn variable_stroke_update_drag(&mut self, new_pos: Vector) { - self.state.variable_stroke.workspace.camera.pos = new_pos; - } } - -fn on_update_point(event: PointEditUpdate) -> AppMessage { - AppMessage::VariableStroke(VariableStrokeMessage::PointEdited(event)) -} - -fn on_update_size(size: Size) -> AppMessage { - AppMessage::VariableStroke(VariableStrokeMessage::WorkspaceSized(size)) -} - -fn on_update_zoom(zoom: Camera) -> AppMessage { - AppMessage::VariableStroke(VariableStrokeMessage::WorkspaceZoomed(zoom)) -} - -fn on_update_drag(drag: Vector) -> AppMessage { - AppMessage::VariableStroke(VariableStrokeMessage::WorkspaceDragged(drag)) -} - impl Default for WorkspaceState { fn default() -> Self { WorkspaceState { scale: 1.0, camera: Camera::empty(), + sheet_state: Default::default(), + point_state: Default::default(), variable_input: vec![], centerline_input: vec![], stroke_output: vec![], diff --git a/examples/overlay_editor/src/data/boolean.rs b/examples/overlay_editor/src/data/boolean.rs index 16b21653..8a92fb34 100644 --- a/examples/overlay_editor/src/data/boolean.rs +++ b/examples/overlay_editor/src/data/boolean.rs @@ -37,6 +37,7 @@ impl BooleanTest { } } + #[cfg(not(target_arch = "wasm32"))] fn tests_count(folder: &str) -> usize { let folder_path = PathBuf::from(folder); match std::fs::read_dir(folder_path) { diff --git a/examples/overlay_editor/src/data/outline.rs b/examples/overlay_editor/src/data/outline.rs index f91b4b32..0afbc3f2 100644 --- a/examples/overlay_editor/src/data/outline.rs +++ b/examples/overlay_editor/src/data/outline.rs @@ -33,6 +33,7 @@ impl OutlineTest { } } + #[cfg(not(target_arch = "wasm32"))] fn tests_count(folder: &str) -> usize { let folder_path = PathBuf::from(folder); match std::fs::read_dir(folder_path) { diff --git a/examples/overlay_editor/src/data/string.rs b/examples/overlay_editor/src/data/string.rs index 71a75f4f..08fc4c86 100644 --- a/examples/overlay_editor/src/data/string.rs +++ b/examples/overlay_editor/src/data/string.rs @@ -39,6 +39,7 @@ impl StringTest { } } + #[cfg(not(target_arch = "wasm32"))] fn tests_count(folder: &str) -> usize { let folder_path = PathBuf::from(folder); match std::fs::read_dir(folder_path) { diff --git a/examples/overlay_editor/src/data/stroke.rs b/examples/overlay_editor/src/data/stroke.rs index 2e583aef..894994d0 100644 --- a/examples/overlay_editor/src/data/stroke.rs +++ b/examples/overlay_editor/src/data/stroke.rs @@ -33,6 +33,7 @@ impl StrokeTest { } } + #[cfg(not(target_arch = "wasm32"))] fn tests_count(folder: &str) -> usize { let folder_path = PathBuf::from(folder); match std::fs::read_dir(folder_path) { diff --git a/examples/overlay_editor/src/data/variable_stroke.rs b/examples/overlay_editor/src/data/variable_stroke.rs index 291d0189..304d214f 100644 --- a/examples/overlay_editor/src/data/variable_stroke.rs +++ b/examples/overlay_editor/src/data/variable_stroke.rs @@ -37,6 +37,7 @@ impl VariableStrokeTest { } } + #[cfg(not(target_arch = "wasm32"))] fn tests_count(folder: &str) -> usize { let folder_path = PathBuf::from(folder); match std::fs::read_dir(folder_path) { diff --git a/examples/overlay_editor/src/draw/path.rs b/examples/overlay_editor/src/draw/path.rs index b9efcfaf..a4766844 100644 --- a/examples/overlay_editor/src/draw/path.rs +++ b/examples/overlay_editor/src/draw/path.rs @@ -1,197 +1,160 @@ use crate::geom::camera::Camera; -use crate::geom::vector::VectorExt; -use i_mesh::path::butt::ButtStrokeBuilder; -use i_mesh::path::style::StrokeStyle; -use i_triangle::float::builder::TriangulationBuilder; -use i_triangle::float::triangulation::Triangulation; -use i_triangle::i_overlay::i_float::float::point::FloatPoint; -use i_triangle::i_overlay::i_float::int::point::IntPoint; -use i_triangle::i_overlay::i_shape::int::path::{IntPath as RawIntPath, IntPaths as RawIntPaths}; -use iced::advanced::graphics::color::pack; -use iced::advanced::graphics::mesh::{Indexed, SolidVertex2D}; -use iced::advanced::graphics::Mesh; -use iced::advanced::layout::{self, Layout}; -use iced::advanced::renderer; -use iced::advanced::widget::{Tree, Widget}; -use iced::{mouse, Color, Transformation, Vector}; -use iced::{Element, Length, Rectangle, Renderer, Size, Theme}; - -type IntPath = RawIntPath; -type IntPaths = RawIntPaths; +use eframe::egui::{Color32, Mesh, Painter, Pos2, Shape, Stroke, Vec2}; +use i_mesh::path::{round::RoundStrokeBuilder, style::StrokeStyle}; +use i_triangle::i_overlay::i_shape::int::path::IntPaths; pub(crate) struct PathWidget { - stroke: Option, + shapes: Vec, } impl PathWidget { pub(crate) fn with_paths( - paths: &IntPaths, - camera: Camera, - stroke_color: Color, - stroke_width: f32, - arrows: bool, - ) -> Self { - let offset = Self::offset_for_paths(paths, camera); - let stroke = - Self::stroke_mesh_for_paths(paths, camera, offset, stroke_color, stroke_width, arrows); - Self { stroke } - } - - fn stroke_mesh_for_paths( - paths: &IntPaths, + paths: &IntPaths, camera: Camera, - offset: Vector, - color: Color, + color: Color32, width: f32, arrows: bool, - ) -> Option { - if paths.is_empty() { - return None; - } - - let mut builder = TriangulationBuilder::default(); - - for path in paths.iter() { - Self::append_path(&mut builder, camera, path, width, arrows); - } - - let s = if arrows { 2.5 * width } else { 0.5 * width }; - - let offset = Vector::new(offset.x - s, offset.y - s); - - let triangulation = builder.build(); - - Self::stroke_mesh_for_triangulation(triangulation, offset, color) - } - - fn stroke_mesh_for_triangulation( - triangulation: Triangulation, usize>, - offset: Vector, - color: Color, - ) -> Option { - if triangulation.indices.is_empty() { - return None; + ) -> Self { + let stroke = Stroke::new(width, color); + let mut shapes = Vec::new(); + for path in paths { + let points: Vec<_> = path + .iter() + .map(|&p| camera.int_world_to_view(p).to_pos2()) + .collect(); + if arrows { + for segment in points.windows(2) { + if let Some(arrow) = arrow_shape( + segment[0], + segment[1], + segment[0].lerp(segment[1], 0.5), + stroke, + ) { + shapes.push(arrow); + } + } + } + if let Some(shape) = stroke_path(points, false, stroke) { + shapes.push(shape); + } } - let color_pack = pack(color); - let vertices = triangulation - .points - .iter() - .map(|&p| SolidVertex2D { - position: [p.x - offset.x, p.y - offset.y], - color: color_pack, - }) - .collect(); - - let indices = triangulation.indices.iter().map(|&i| i as u32).collect(); - - Some(Mesh::Solid { - buffers: Indexed { vertices, indices }, - transformation: Transformation::translate(offset.x, offset.y), - clip_bounds: Rectangle::INFINITE, - }) + Self { shapes } } - fn offset_for_paths(paths: &IntPaths, camera: Camera) -> Vector { - if paths.is_empty() { - return Vector::new(0.0, 0.0); - } - - let mut min_x = i32::MAX; - let mut max_y = i32::MIN; - - for p in paths.iter().flatten() { - min_x = min_x.min(p.x); - max_y = max_y.max(p.y); + pub(crate) fn paint(self, painter: &Painter) { + for mut shape in self.shapes { + shape.translate(painter.clip_rect().min.to_vec2()); + painter.add(shape); } - - camera.int_world_to_view(IntPoint::new(min_x, max_y)) } +} - fn append_path( - builder: &mut TriangulationBuilder, usize>, - camera: Camera, - path: &IntPath, - width: f32, - arrows: bool, - ) { - let stroke_builder = ButtStrokeBuilder::new(StrokeStyle::with_width(width)); - let screen_path: Vec<_> = path - .iter() - .map(|&p| { - let v = camera.int_world_to_view(p); - FloatPoint::new(v.x, v.y) - }) - .collect(); - - let sub_triangulation = stroke_builder.build_open_path_mesh::(&screen_path); - builder.append(sub_triangulation); - - let r2 = 2.0 * width; - let r4 = 4.0 * width; - - if arrows { - let mut a = screen_path[0]; - for &b in screen_path.iter().skip(1) { - let m = (a + b) * 0.5; - let n = (b - a).normalize(); - let m0 = m - n * r4; - let t0 = FloatPoint::new(-n.y, n.x) * r2; - let t1 = FloatPoint::new(n.y, -n.x) * r2; - let v0 = m0 + t0; - let v1 = m0 + t1; - - let arrow_triangulation = - stroke_builder.build_open_path_mesh::(&[v0, m, v1]); - builder.append(arrow_triangulation); - - a = b; - } - } +pub(super) fn arrow_shape(a: Pos2, b: Pos2, tip: Pos2, stroke: Stroke) -> Option { + let delta = b - a; + if delta.length_sq() < 1e-10 { + return None; } + let n = delta.normalized(); + let base = tip - n * (4.0 * stroke.width); + let side = Vec2::new(-n.y, n.x) * (2.0 * stroke.width); + stroke_path(vec![base + side, tip, base - side], false, stroke) } -impl Widget for PathWidget { - fn size(&self) -> Size { - Size { - width: Length::Fill, - height: Length::Fill, - } +/// Build screen-space round joins/caps without changing editable geometry. +pub(super) fn stroke_path(mut points: Vec, closed: bool, stroke: Stroke) -> Option { + points.dedup(); + if closed && points.first() == points.last() { + points.pop(); } - - fn layout( - &mut self, - _tree: &mut Tree, - _renderer: &Renderer, - limits: &layout::Limits, - ) -> layout::Node { - layout::Node::new(limits.max()) + if points.len() < 2 || stroke.is_empty() { + return None; } - fn draw( - &self, - _tree: &Tree, - renderer: &mut Renderer, - _theme: &Theme, - _style: &renderer::Style, - layout: Layout<'_>, - _cursor: mouse::Cursor, - _viewport: &Rectangle, - ) { - use iced::advanced::graphics::mesh::Renderer as _; - use iced::advanced::Renderer as _; + let path: Vec<[f32; 2]> = points.iter().map(|p| [p.x, p.y]).collect(); + let builder = RoundStrokeBuilder::new(StrokeStyle::with_width(stroke.width)); + let triangulation = if closed { + builder.build_closed_path_mesh::(&path) + } else { + builder.build_open_path_mesh::(&path) + }; + let mut mesh = Mesh::default(); + for [x, y] in triangulation.points { + mesh.colored_vertex(Pos2::new(x, y), stroke.color); + } + mesh.indices = triangulation.indices; + Some(Shape::mesh(mesh)) +} - let bounds = layout.bounds(); - renderer.with_layer(bounds, |renderer| { - let offset = Vector::point(layout.position()); - if let Some(mesh) = &self.stroke { - renderer.with_translation(offset, |renderer| renderer.draw_mesh(mesh.clone())); +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn degenerate_strokes_tessellate_to_finite_vertices() { + let ctx = eframe::egui::Context::default(); + let a = Pos2::new(10.0, 10.0); + let b = Pos2::new(20.0, 10.0); + let c = Pos2::new(30.0, 10.0); + for points in [ + vec![], + vec![a], + vec![a, a, a], + vec![a, b], + vec![a, a, b, b, b, c, a], + vec![a, b, a, c], + ] { + for closed in [false, true] { + let output = ctx.run_ui(Default::default(), |ui| { + if let Some(shape) = + stroke_path(points.clone(), closed, Stroke::new(2.0_f32, Color32::WHITE)) + { + ui.painter().add(shape); + } + }); + for primitive in ctx.tessellate(output.shapes, output.pixels_per_point) { + if let eframe::egui::epaint::Primitive::Mesh(mesh) = primitive.primitive { + assert!(mesh.vertices.iter().all(|v| v.pos.is_finite())); + } + } } - }); + } + } + #[test] + fn round_strokes_stay_within_half_width_of_path_bounds() { + let width = 4.0; + for closed in [false, true] { + // Nearly reversing at the tip previously produced long miter spikes. + let points = vec![ + Pos2::new(100.0, 100.0), + Pos2::new(300.0, 100.0), + Pos2::new(100.0, 100.4), + ]; + let bounds = eframe::egui::Rect::from_points(&points).expand(width * 0.5 + 0.001); + let Shape::Mesh(mesh) = + stroke_path(points, closed, Stroke::new(width, Color32::WHITE)).unwrap() + else { + panic!("expected stroke mesh"); + }; + assert!(mesh.is_valid()); + assert!(!mesh.indices.is_empty()); + assert!(mesh.vertices.iter().all(|v| bounds.contains(v.pos))); + } } -} -impl<'a, Message: 'a> From for Element<'a, Message> { - fn from(editor: PathWidget) -> Self { - Self::new(editor) + #[test] + fn open_stroke_has_round_caps() { + let Shape::Mesh(mesh) = stroke_path( + vec![Pos2::new(10.0, 10.0), Pos2::new(30.0, 10.0)], + false, + Stroke::new(4.0_f32, Color32::WHITE), + ) + .unwrap() else { + panic!("expected stroke mesh"); + }; + assert!(mesh.vertices.iter().any(|v| v.pos.x < 8.1)); + assert!(mesh.vertices.iter().any(|v| v.pos.x > 31.9)); + assert!(mesh.vertices.iter().all(|v| { + let nearest = Pos2::new(v.pos.x.clamp(10.0, 30.0), 10.0); + v.pos.distance(nearest) <= 2.001 + })); } } diff --git a/examples/overlay_editor/src/draw/shape.rs b/examples/overlay_editor/src/draw/shape.rs index 64ae6bce..013ec365 100644 --- a/examples/overlay_editor/src/draw/shape.rs +++ b/examples/overlay_editor/src/draw/shape.rs @@ -1,315 +1,163 @@ use crate::geom::camera::Camera; -use crate::geom::vector::VectorExt; -use i_mesh::path::butt::ButtStrokeBuilder; -use i_mesh::path::style::StrokeStyle; -use i_triangle::float::builder::TriangulationBuilder; -use i_triangle::float::triangulation::Triangulation; +use eframe::egui::{Color32, Mesh, Painter, Shape, Stroke}; use i_triangle::i_overlay::core::fill_rule::FillRule; -use i_triangle::i_overlay::i_float::float::point::FloatPoint; -use i_triangle::i_overlay::i_float::int::point::IntPoint; -use i_triangle::i_overlay::i_shape::int::count::PointsCount; -use i_triangle::i_overlay::i_shape::int::path::IntPaths as RawIntPaths; -use i_triangle::i_overlay::i_shape::int::shape::IntShapes as RawIntShapes; -use i_triangle::int::triangulation::IntTriangulation; -use i_triangle::int::triangulator::IntTriangulator; -use i_triangle::int::validation::Validation; -use iced::advanced::graphics::color::pack; -use iced::advanced::graphics::mesh::{Indexed, SolidVertex2D}; -use iced::advanced::graphics::Mesh; -use iced::advanced::layout::{self, Layout}; -use iced::advanced::renderer; -use iced::advanced::widget::{Tree, Widget}; -use iced::{mouse, Color, Transformation, Vector}; -use iced::{Element, Length, Rectangle, Renderer, Size, Theme}; - -type IntPaths = RawIntPaths; -type IntShapes = RawIntShapes; +use i_triangle::i_overlay::i_shape::int::{count::PointsCount, path::IntPaths, shape::IntShapes}; +use i_triangle::int::{ + triangulation::IntTriangulation, triangulator::IntTriangulator, validation::Validation, +}; pub(crate) struct ShapeWidget { fill: Option, - stroke: Option, + strokes: Vec, } impl ShapeWidget { pub(crate) fn with_shapes( - shapes: &IntShapes, + shapes: &IntShapes, camera: Camera, fill_rule: Option, - fill_color: Option, - stroke_color: Option, + fill_color: Option, + stroke_color: Option, stroke_width: f32, ) -> Self { - let offset = Self::offset_for_shapes(shapes, camera); - let fill = Self::fill_mesh_for_shapes(shapes, camera, offset, fill_rule, fill_color); - let stroke = - Self::stroke_mesh_for_shapes(shapes, camera, offset, stroke_color, stroke_width); - Self { fill, stroke } + let fill = fill_color.filter(|_| !shapes.is_empty()).map(|color| { + let triangulation = IntTriangulator::new( + shapes.points_count(), + Validation::with_fill_rule(fill_rule.unwrap_or_default()), + Default::default(), + ) + .triangulate_shapes(shapes); + Self::fill_mesh(triangulation, camera, color) + }); + let strokes = shapes + .iter() + .flat_map(|paths| Self::strokes(paths, camera, stroke_color, stroke_width)) + .collect(); + Self { fill, strokes } } pub(crate) fn with_paths( - paths: &IntPaths, + paths: &IntPaths, camera: Camera, fill_rule: Option, - fill_color: Option, - stroke_color: Option, + fill_color: Option, + stroke_color: Option, stroke_width: f32, ) -> Self { - let offset = Self::offset_for_paths(paths, camera); - let fill = Self::fill_mesh_for_paths(paths, camera, offset, fill_rule, fill_color); - let stroke = Self::stroke_mesh_for_paths(paths, camera, offset, stroke_color, stroke_width); - Self { fill, stroke } - } - - fn fill_mesh_for_shapes( - shapes: &IntShapes, - camera: Camera, - offset: Vector, - fill_rule: Option, - color: Option, - ) -> Option { - if shapes.is_empty() { - return None; - } - let color = color?; - let validation = Validation::with_fill_rule(fill_rule.unwrap_or_default()); - let triangulation = - IntTriangulator::new(shapes.points_count(), validation, Default::default()) - .triangulate_shapes(shapes); - - Self::fill_mesh_for_triangulation(triangulation, camera, offset, color) - } - - fn fill_mesh_for_paths( - paths: &IntPaths, - camera: Camera, - offset: Vector, - fill_rule: Option, - color: Option, - ) -> Option { - if paths.is_empty() { - return None; + let fill = fill_color.filter(|_| !paths.is_empty()).map(|color| { + let triangulation = IntTriangulator::new( + paths.points_count(), + Validation::with_fill_rule(fill_rule.unwrap_or_default()), + Default::default(), + ) + .triangulate_shape(paths); + Self::fill_mesh(triangulation, camera, color) + }); + Self { + fill, + strokes: Self::strokes(paths, camera, stroke_color, stroke_width), } - let color = color?; - - let validation = Validation::with_fill_rule(fill_rule.unwrap_or_default()); - let triangulation = - IntTriangulator::new(paths.points_count(), validation, Default::default()) - .triangulate_shape(paths); - - // let triangulation = paths.triangulate().into_triangulation(); - - Self::fill_mesh_for_triangulation(triangulation, camera, offset, color) } - fn fill_mesh_for_triangulation( + fn fill_mesh( triangulation: IntTriangulation, camera: Camera, - offset: Vector, - color: Color, - ) -> Option { - let indices = triangulation.indices; - if indices.is_empty() { - return None; + color: Color32, + ) -> Mesh { + let mut mesh = Mesh::default(); + for p in triangulation.points { + mesh.colored_vertex(camera.int_world_to_view(p).to_pos2(), color); } - let color_pack = pack(color); - let vertices = triangulation - .points - .iter() - .map(|&p| { - let v = camera.int_world_to_view(p); - SolidVertex2D { - position: [v.x - offset.x, v.y - offset.y], - color: color_pack, - } - }) + mesh.indices = triangulation + .indices + .into_iter() + .map(|i| i as u32) .collect(); - - let indices = indices.iter().map(|&i| i as u32).collect(); - - Some(Mesh::Solid { - buffers: Indexed { vertices, indices }, - transformation: Transformation::translate(offset.x, offset.y), - clip_bounds: Rectangle::INFINITE, - }) + mesh } - fn stroke_mesh_for_shapes( - shapes: &IntShapes, + fn strokes( + paths: &IntPaths, camera: Camera, - offset: Vector, - color: Option, + color: Option, width: f32, - ) -> Option { - if shapes.is_empty() { - return None; - } - let color = color?; - let stroke_builder = ButtStrokeBuilder::new(StrokeStyle::with_width(width)); - - let mut builder = TriangulationBuilder::default(); - for shape in shapes.iter() { - for path in shape.iter() { - let world_path: Vec> = path - .iter() - .map(|&p| { - let v = camera.int_world_to_view(p); - FloatPoint::new(v.x, v.y) - }) - .collect(); - - let sub_triangulation = stroke_builder.build_closed_path_mesh::(&world_path); - builder.append(sub_triangulation); - } - } - let r = 0.5 * width; - let offset = Vector::new(offset.x - r, offset.y - r); - - let triangulation = builder.build(); - - Self::stroke_mesh_for_triangulation(triangulation, offset, color) - } - - fn stroke_mesh_for_paths( - paths: &IntPaths, - camera: Camera, - offset: Vector, - color: Option, - width: f32, - ) -> Option { - if paths.is_empty() { - return None; - } - let color = color?; - let stroke_builder = ButtStrokeBuilder::new(StrokeStyle::with_width(width)); - - let mut builder = TriangulationBuilder::default(); - - for path in paths.iter() { - let world_path: Vec<_> = path - .iter() - .map(|&p| { - let v = camera.int_world_to_view(p); - FloatPoint::new(v.x, v.y) - }) - .collect(); - - let sub_triangulation = stroke_builder.build_closed_path_mesh::(&world_path); - builder.append(sub_triangulation); - } - - let r = 0.5 * width; - let offset = Vector::new(offset.x - r, offset.y - r); - - let triangulation = builder.build(); - - Self::stroke_mesh_for_triangulation(triangulation, offset, color) - } - - fn stroke_mesh_for_triangulation( - triangulation: Triangulation, usize>, - offset: Vector, - color: Color, - ) -> Option { - if triangulation.indices.is_empty() { - return None; - } - let color_pack = pack(color); - let vertices = triangulation - .points + ) -> Vec { + let Some(color) = color else { + return Vec::new(); + }; + paths .iter() - .map(|&p| SolidVertex2D { - position: [p.x - offset.x, p.y - offset.y], - color: color_pack, + .filter_map(|path| { + crate::draw::path::stroke_path( + path.iter() + .map(|&p| camera.int_world_to_view(p).to_pos2()) + .collect(), + true, + Stroke::new(width, color), + ) }) - .collect(); - - let indices = triangulation.indices.iter().map(|&i| i as u32).collect(); - - Some(Mesh::Solid { - buffers: Indexed { vertices, indices }, - transformation: Transformation::translate(offset.x, offset.y), - clip_bounds: Rectangle::INFINITE, - }) + .collect() } - fn offset_for_shapes(shapes: &IntShapes, camera: Camera) -> Vector { - if shapes.is_empty() { - return Vector::new(0.0, 0.0); + pub(crate) fn paint(self, painter: &Painter) { + let offset = painter.clip_rect().min.to_vec2(); + if let Some(mut mesh) = self.fill { + mesh.translate(offset); + painter.add(mesh); } - - let mut min_x = i32::MAX; - let mut max_y = i32::MIN; - - for p in shapes.iter().flatten().flatten() { - min_x = min_x.min(p.x); - max_y = max_y.max(p.y); + for mut shape in self.strokes { + shape.translate(offset); + painter.add(shape); } - - camera.int_world_to_view(IntPoint::new(min_x, max_y)) - } - - fn offset_for_paths(paths: &IntPaths, camera: Camera) -> Vector { - if paths.is_empty() { - return Vector::new(0.0, 0.0); - } - - let mut min_x = i32::MAX; - let mut max_y = i32::MIN; - - for p in paths.iter().flatten() { - min_x = min_x.min(p.x); - max_y = max_y.max(p.y); - } - - camera.int_world_to_view(IntPoint::new(min_x, max_y)) - } -} - -impl Widget for ShapeWidget { - fn size(&self) -> Size { - Size { - width: Length::Fill, - height: Length::Fill, - } - } - - fn layout( - &mut self, - _tree: &mut Tree, - _renderer: &Renderer, - limits: &layout::Limits, - ) -> layout::Node { - layout::Node::new(limits.max()) - } - - fn draw( - &self, - _tree: &Tree, - renderer: &mut Renderer, - _theme: &Theme, - _style: &renderer::Style, - layout: Layout<'_>, - _cursor: mouse::Cursor, - _viewport: &Rectangle, - ) { - use iced::advanced::graphics::mesh::Renderer as _; - use iced::advanced::Renderer as _; - - let bounds = layout.bounds(); - renderer.with_layer(bounds, |renderer| { - let offset = Vector::point(layout.position()); - if let Some(mesh) = &self.fill { - renderer.with_translation(offset, |renderer| renderer.draw_mesh(mesh.clone())); - } - if let Some(mesh) = &self.stroke { - renderer.with_translation(offset, |renderer| renderer.draw_mesh(mesh.clone())); - } - }); } } -impl<'a, Message: 'a> From for Element<'a, Message> { - fn from(editor: ShapeWidget) -> Self { - Self::new(editor) +#[cfg(test)] +mod tests { + use super::*; + use eframe::egui::Vec2; + use i_triangle::i_overlay::i_float::int::{point::IntPoint, rect::IntRect}; + + #[test] + fn fill_preserves_holes_and_strokes_are_stroke_paths() { + let paths = vec![ + vec![ + IntPoint::new(0, 0), + IntPoint::new(0, 100), + IntPoint::new(100, 100), + IntPoint::new(100, 0), + ], + vec![ + IntPoint::new(25, 25), + IntPoint::new(75, 25), + IntPoint::new(75, 75), + IntPoint::new(25, 75), + ], + ]; + let camera = Camera::new(IntRect::new(0, 100, 0, 100), Vec2::new(200.0, 200.0)); + let widget = ShapeWidget::with_paths( + &paths, + camera, + Some(FillRule::EvenOdd), + Some(Color32::WHITE), + Some(Color32::RED), + 2.0, + ); + let mesh = widget.fill.unwrap(); + let area: f32 = mesh + .indices + .chunks_exact(3) + .map(|t| { + let a = mesh.vertices[t[0] as usize].pos; + let b = mesh.vertices[t[1] as usize].pos; + let c = mesh.vertices[t[2] as usize].pos; + ((b - a).x * (c - a).y - (b - a).y * (c - a).x).abs() * 0.5 + }) + .sum(); + assert!((area - 7500.0).abs() < 0.01); + assert_eq!(widget.strokes.len(), 2); + assert!(widget.strokes.iter().all( + |s| matches!(s, Shape::Mesh(mesh) if mesh.is_valid() && !mesh.indices.is_empty()) + )); } } diff --git a/examples/overlay_editor/src/draw/varicolored.rs b/examples/overlay_editor/src/draw/varicolored.rs index 191bac72..5118f035 100644 --- a/examples/overlay_editor/src/draw/varicolored.rs +++ b/examples/overlay_editor/src/draw/varicolored.rs @@ -1,249 +1,49 @@ -use crate::geom::camera::Camera; -use crate::geom::vector::VectorExt; -use i_mesh::path::butt::ButtStrokeBuilder; -use i_mesh::path::style::StrokeStyle; -use i_triangle::float::builder::TriangulationBuilder; -use i_triangle::float::triangulation::Triangulation; -use i_triangle::i_overlay::i_float::float::point::FloatPoint; -use i_triangle::i_overlay::i_float::int::point::IntPoint; -use i_triangle::i_overlay::i_shape::int::path::IntPaths as RawIntPaths; -use i_triangle::i_overlay::i_shape::int::shape::IntShapes as RawIntShapes; -use i_triangle::int::triangulatable::IntTriangulatable; -use i_triangle::int::triangulation::IntTriangulation; -use iced::advanced::graphics::color::pack; -use iced::advanced::graphics::mesh::{Indexed, SolidVertex2D}; -use iced::advanced::graphics::Mesh; -use iced::advanced::layout::{self, Layout}; -use iced::advanced::renderer; -use iced::advanced::widget::{Tree, Widget}; -use iced::{mouse, Color, Transformation, Vector}; -use iced::{Element, Length, Rectangle, Renderer, Size, Theme}; - -type IntPaths = RawIntPaths; -type IntShapes = RawIntShapes; +use crate::{draw::shape::ShapeWidget, geom::camera::Camera}; +use eframe::egui::{Color32, Painter}; +use i_triangle::i_overlay::i_shape::int::shape::IntShapes; pub(crate) struct VaricoloredWidget { - fill: Vec, - stroke: Vec, + shapes: Vec, } impl VaricoloredWidget { - const SHAPE_COLOR_STORE: [[u8; 3]; 12] = [ - [255, 149, 0], // Orange - [88, 86, 214], // Purple - [255, 45, 85], // Pink - [90, 200, 250], // Blue - [76, 217, 100], // Green - [255, 204, 0], // Yellow - [142, 142, 147], // Gray - [255, 59, 48], // Red - [52, 199, 89], // Green - [0, 122, 255], // Blue - [175, 82, 222], // Indigo - [255, 214, 10], // Teal + const COLORS: [[u8; 3]; 12] = [ + [255, 149, 0], + [88, 86, 214], + [255, 45, 85], + [90, 200, 250], + [76, 217, 100], + [255, 204, 0], + [142, 142, 147], + [255, 59, 48], + [52, 199, 89], + [0, 122, 255], + [175, 82, 222], + [255, 214, 10], ]; - - pub(crate) fn with_shapes(shapes: &IntShapes, camera: Camera, stroke_width: f32) -> Self { - let offset = Self::offset_for_shapes(shapes, camera); - - let mut fill = Vec::new(); - let mut stroke = Vec::new(); - for (index, shape) in shapes.iter().enumerate() { - let data = Self::SHAPE_COLOR_STORE[index % Self::SHAPE_COLOR_STORE.len()]; - let color = Color::from_rgb8(data[0], data[1], data[2]); - - if let Some(mesh) = - Self::fill_mesh_for_paths(shape, camera, offset, color.scale_alpha(0.2)) - { - fill.push(mesh); - } - if let Some(mesh) = - Self::stroke_mesh_for_paths(shape, camera, offset, color, stroke_width) - { - stroke.push(mesh); - } - } - - Self { fill, stroke } - } - - fn fill_mesh_for_paths( - paths: &IntPaths, - camera: Camera, - offset: Vector, - color: Color, - ) -> Option { - if paths.is_empty() { - return None; - } - - let triangulation = paths.triangulate().into_triangulation(); - Self::fill_mesh_for_triangulation(triangulation, camera, offset, color) - } - - fn fill_mesh_for_triangulation( - triangulation: IntTriangulation, - camera: Camera, - offset: Vector, - color: Color, - ) -> Option { - if triangulation.indices.is_empty() { - return None; - } - let color_pack = pack(color); - let vertices = triangulation - .points - .iter() - .map(|&p| { - let v = camera.int_world_to_view(p); - SolidVertex2D { - position: [v.x - offset.x, v.y - offset.y], - color: color_pack, - } - }) - .collect(); - - let indices = triangulation.indices.iter().map(|&i| i as u32).collect(); - - Some(Mesh::Solid { - buffers: Indexed { vertices, indices }, - transformation: Transformation::translate(offset.x, offset.y), - clip_bounds: Rectangle::INFINITE, - }) - } - - fn stroke_mesh_for_paths( - paths: &IntPaths, - camera: Camera, - offset: Vector, - color: Color, - width: f32, - ) -> Option { - if paths.is_empty() { - return None; - } - let stroke_builder = ButtStrokeBuilder::new(StrokeStyle::with_width(width)); - - let mut builder = TriangulationBuilder::default(); - - for path in paths.iter() { - let world_path: Vec<_> = path + pub(crate) fn with_shapes(shapes: &IntShapes, camera: Camera, width: f32) -> Self { + Self { + shapes: shapes .iter() - .map(|&p| { - let v = camera.int_world_to_view(p); - FloatPoint::new(v.x, v.y) + .enumerate() + .map(|(index, paths)| { + let [r, g, b] = Self::COLORS[index % Self::COLORS.len()]; + let color = Color32::from_rgb(r, g, b); + ShapeWidget::with_paths( + paths, + camera, + None, + Some(color.gamma_multiply(0.2)), + Some(color), + width, + ) }) - .collect(); - - let sub_triangulation: Triangulation, usize> = - stroke_builder.build_closed_path_mesh(&world_path); - - builder.append(sub_triangulation); + .collect(), } - - let r = 0.5 * width; - let offset = Vector::new(offset.x - r, offset.y - r); - - let triangulation = builder.build(); - - Self::stroke_mesh_for_triangulation(triangulation, offset, color) } - - fn stroke_mesh_for_triangulation( - triangulation: Triangulation, usize>, - offset: Vector, - color: Color, - ) -> Option { - if triangulation.indices.is_empty() { - return None; - } - let color_pack = pack(color); - let vertices = triangulation - .points - .iter() - .map(|&p| SolidVertex2D { - position: [p.x - offset.x, p.y - offset.y], - color: color_pack, - }) - .collect(); - - let indices = triangulation.indices.iter().map(|&i| i as u32).collect(); - - Some(Mesh::Solid { - buffers: Indexed { vertices, indices }, - transformation: Transformation::translate(offset.x, offset.y), - clip_bounds: Rectangle::INFINITE, - }) - } - - fn offset_for_shapes(shapes: &IntShapes, camera: Camera) -> Vector { - if shapes.is_empty() { - return Vector::new(0.0, 0.0); - } - - let mut min_x = i32::MAX; - let mut max_y = i32::MIN; - - for p in shapes.iter().flatten().flatten() { - min_x = min_x.min(p.x); - max_y = max_y.max(p.y); - } - - camera.int_world_to_view(IntPoint::new(min_x, max_y)) - } -} - -impl Widget for VaricoloredWidget { - fn size(&self) -> Size { - Size { - width: Length::Fill, - height: Length::Fill, + pub(crate) fn paint(self, painter: &Painter) { + for shape in self.shapes { + shape.paint(painter); } } - - fn layout( - &mut self, - _tree: &mut Tree, - _renderer: &Renderer, - limits: &layout::Limits, - ) -> layout::Node { - layout::Node::new(limits.max()) - } - - fn draw( - &self, - _tree: &Tree, - renderer: &mut Renderer, - _theme: &Theme, - _style: &renderer::Style, - layout: Layout<'_>, - _cursor: mouse::Cursor, - _viewport: &Rectangle, - ) { - use iced::advanced::graphics::mesh::Renderer as _; - use iced::advanced::Renderer as _; - - let bounds = layout.bounds(); - renderer.with_layer(bounds, |renderer| { - let offset = Vector::point(layout.position()); - - renderer.with_translation(offset, |renderer| { - for mesh in self.fill.iter() { - renderer.draw_mesh(mesh.clone()) - } - }); - - renderer.with_translation(offset, |renderer| { - for mesh in self.stroke.iter() { - renderer.draw_mesh(mesh.clone()) - } - }); - }); - } -} - -impl<'a, Message: 'a> From for Element<'a, Message> { - fn from(editor: VaricoloredWidget) -> Self { - Self::new(editor) - } } diff --git a/examples/overlay_editor/src/draw/vectors.rs b/examples/overlay_editor/src/draw/vectors.rs index 46b7a031..e3d23577 100644 --- a/examples/overlay_editor/src/draw/vectors.rs +++ b/examples/overlay_editor/src/draw/vectors.rs @@ -1,320 +1,74 @@ +use crate::draw::path::{arrow_shape, stroke_path}; use crate::geom::camera::Camera; -use crate::geom::vector::VectorExt; -use i_mesh::path::butt::ButtStrokeBuilder; -use i_mesh::path::style::StrokeStyle; -use i_triangle::float::triangulation::Triangulation; -use i_triangle::i_overlay::i_float::float::point::FloatPoint; -use i_triangle::i_overlay::i_float::int::point::IntPoint; +use eframe::egui::{Color32, Painter, Shape, Stroke, Vec2}; use i_triangle::i_overlay::vector::edge::{ - DataVectorEdge, SideFill, CLIP_LEFT, CLIP_RIGHT, SUBJ_LEFT, SUBJ_RIGHT, + DataVectorEdge, CLIP_LEFT, CLIP_RIGHT, SUBJ_LEFT, SUBJ_RIGHT, }; -use iced::advanced::graphics::color::pack; -use iced::advanced::graphics::mesh::{Indexed, SolidVertex2D}; -use iced::advanced::graphics::{color, Mesh}; -use iced::advanced::layout::{self, Layout}; -use iced::advanced::renderer; -use iced::advanced::widget::{Tree, Widget}; -use iced::{mouse, Color, Transformation, Vector}; -use iced::{Element, Length, Rectangle, Renderer, Size, Theme}; -use std::f32::consts::PI; - -type VectorEdge = DataVectorEdge; - -struct ColorSchema { - subj: color::Packed, - subj_none: color::Packed, - clip: color::Packed, - clip_none: color::Packed, - both: color::Packed, - none: color::Packed, -} - -impl ColorSchema { - fn new(subj: Color, clip: Color, both: Color) -> Self { - Self { - subj: pack(subj), - subj_none: pack(subj.scale_alpha(0.05)), - clip: pack(clip), - clip_none: pack(clip.scale_alpha(0.05)), - both: pack(both), - none: pack(Color::from_rgb8(127, 127, 127)), - } - } - - fn color(&self, fill: SideFill) -> color::Packed { - let subj = fill & (SUBJ_LEFT | SUBJ_RIGHT) != 0; - let clip = fill & (CLIP_LEFT | CLIP_RIGHT) != 0; - match (subj, clip) { - (true, true) => self.both, - (true, false) => self.subj, - (false, true) => self.clip, - (false, false) => self.none, - } - } - - fn subj_right(&self, fill: SideFill) -> color::Packed { - if fill & SUBJ_RIGHT != 0 { - self.subj - } else { - self.subj_none - } - } - - fn subj_left(&self, fill: SideFill) -> color::Packed { - if fill & SUBJ_LEFT != 0 { - self.subj - } else { - self.subj_none - } - } - - fn clip_right(&self, fill: SideFill) -> color::Packed { - if fill & CLIP_RIGHT != 0 { - self.clip - } else { - self.clip_none - } - } - - fn clip_left(&self, fill: SideFill) -> color::Packed { - if fill & CLIP_LEFT != 0 { - self.clip - } else { - self.clip_none - } - } -} pub(crate) struct VectorsWidget { - stroke: Option, + shapes: Vec, } impl VectorsWidget { pub(crate) fn with_vectors( - vectors: &[VectorEdge], - camera: Camera, - subj: Color, - clip: Color, - both: Color, - stroke_width: f32, - ) -> Self { - let schema = ColorSchema::new(subj, clip, both); - let offset = Self::offset_for_vectors(vectors, camera); - let stroke = Self::stroke_mesh_for_paths(vectors, camera, offset, schema, stroke_width); - Self { stroke } - } - - fn stroke_mesh_for_paths( - vectors: &[VectorEdge], + vectors: &[DataVectorEdge], camera: Camera, - offset: Vector, - schema: ColorSchema, + subj: Color32, + clip: Color32, + both: Color32, width: f32, - ) -> Option { - if vectors.is_empty() { - return None; - } - - let mut builder = MeshBuilder::new(); - - let s = 8.0 * width; - let offset = Vector::new(offset.x - s, offset.y - s); - - for vector in vectors.iter() { - Self::append_vector(&mut builder, camera, vector, offset, &schema, width); - } - - let buffers = builder.build(); - - Some(Mesh::Solid { - buffers, - transformation: Transformation::translate(offset.x, offset.y), - clip_bounds: Rectangle::INFINITE, - }) - } - - fn offset_for_vectors(vectors: &[VectorEdge], camera: Camera) -> Vector { - if vectors.is_empty() { - return Vector::new(0.0, 0.0); - } - - let mut min_x = i32::MAX; - let mut max_y = i32::MIN; - - for v in vectors.iter() { - min_x = min_x.min(v.a.x); - min_x = min_x.min(v.b.x); - max_y = max_y.max(v.a.y); - max_y = max_y.max(v.b.y); - } - - camera.int_world_to_view(IntPoint::new(min_x, max_y)) - } - - fn append_vector( - builder: &mut MeshBuilder, - camera: Camera, - vector: &VectorEdge, - offset: Vector, - schema: &ColorSchema, - width: f32, - ) { - let stroke_builder = ButtStrokeBuilder::new(StrokeStyle::with_width(width)); - let path = [vector.a, vector.b]; - let screen_path: Vec<_> = path - .iter() - .map(|&p| { - let v = camera.int_world_to_view(p); - FloatPoint::new(v.x - offset.x, v.y - offset.y) - }) - .collect(); - - let segment_color = schema.color(vector.fill); - - let sub_triangulation = stroke_builder.build_open_path_mesh(&screen_path); - builder.append(sub_triangulation, segment_color); - - let r2 = 4.0 * width; - - let a = screen_path[0]; - let b = screen_path[1]; - - let n = (b - a).normalize(); - let m = (a + b) * 0.5; - let m0 = b - n * r2; - let t0 = FloatPoint::new(-n.y, n.x) * r2; - let t1 = FloatPoint::new(n.y, -n.x) * r2; - let s0 = n * r2; - let s1 = -n * r2; - let v0 = m0 + t0 * 0.5; - let v1 = m0 + t1 * 0.5; - - let subj_right = schema.subj_right(vector.fill); - let clip_right = schema.clip_right(vector.fill); - let subj_left = schema.subj_left(vector.fill); - let clip_left = schema.clip_left(vector.fill); - - let subj_right_pos = m + t0 + s0; - let subj_left_pos = m + t1 + s0; - - let clip_right_pos = m + t0 + s1; - let clip_left_pos = m + t1 + s1; - - Self::append_circle(builder, subj_right_pos, subj_right, 2.0 * width); - Self::append_circle(builder, clip_right_pos, clip_right, 2.0 * width); - Self::append_circle(builder, subj_left_pos, subj_left, 2.0 * width); - Self::append_circle(builder, clip_left_pos, clip_left, 2.0 * width); - - let arrow_triangulation = stroke_builder.build_open_path_mesh(&[v0, b, v1]); - builder.append(arrow_triangulation, segment_color); - } - - fn append_circle( - builder: &mut MeshBuilder, - pos: FloatPoint, - color: color::Packed, - radius: f32, - ) { - let n = 8; - let da = 2.0 * PI / n as f32; - let mut a = 0.0f32; - let mut indices = Vec::with_capacity(3 * n); - let mut points = Vec::with_capacity(n); - for i in 0..n { - let sc = a.sin_cos(); - let x = pos.x + sc.1 * radius; - let y = pos.y + sc.0 * radius; - points.push(FloatPoint::new(x, y)); - indices.extend(&[n, i, (i + 1) % n]); - a += da; - } - - points.push(pos); - - builder.append(Triangulation { points, indices }, color); - } -} - -impl Widget for VectorsWidget { - fn size(&self) -> Size { - Size { - width: Length::Fill, - height: Length::Fill, - } - } - - fn layout( - &mut self, - _tree: &mut Tree, - _renderer: &Renderer, - limits: &layout::Limits, - ) -> layout::Node { - layout::Node::new(limits.max()) - } - - fn draw( - &self, - _tree: &Tree, - renderer: &mut Renderer, - _theme: &Theme, - _style: &renderer::Style, - layout: Layout<'_>, - _cursor: mouse::Cursor, - _viewport: &Rectangle, - ) { - use iced::advanced::graphics::mesh::Renderer as _; - use iced::advanced::Renderer as _; - - let bounds = layout.bounds(); - renderer.with_layer(bounds, |renderer| { - let offset = Vector::point(layout.position()); - if let Some(mesh) = &self.stroke { - renderer.with_translation(offset, |renderer| renderer.draw_mesh(mesh.clone())); + ) -> Self { + let mut shapes = Vec::new(); + for vector in vectors { + let a = camera.int_world_to_view(vector.a).to_pos2(); + let b = camera.int_world_to_view(vector.b).to_pos2(); + if (b - a).length_sq() < 1e-10 { + continue; + } + let fill = vector.fill; + let color = match ( + fill & (SUBJ_LEFT | SUBJ_RIGHT) != 0, + fill & (CLIP_LEFT | CLIP_RIGHT) != 0, + ) { + (true, true) => both, + (true, false) => subj, + (false, true) => clip, + _ => Color32::GRAY, + }; + let stroke = Stroke::new(width, color); + if let Some(line) = stroke_path(vec![a, b], false, stroke) { + shapes.push(line); + } + if let Some(arrow) = arrow_shape(a, b, b, stroke) { + shapes.push(arrow); + } + let n = (b - a).normalized() * (4.0 * width); + let side = Vec2::new(-n.y, n.x); + let mid = a.lerp(b, 0.5); + for (pos, mask, color) in [ + (mid + side + n, SUBJ_RIGHT, subj), + (mid - side + n, SUBJ_LEFT, subj), + (mid + side - n, CLIP_RIGHT, clip), + (mid - side - n, CLIP_LEFT, clip), + ] { + shapes.push(Shape::circle_filled( + pos, + 2.0 * width, + if fill & mask != 0 { + color + } else { + color.gamma_multiply(0.05) + }, + )); } - }); - } -} - -impl<'a, Message: 'a> From for Element<'a, Message> { - fn from(editor: VectorsWidget) -> Self { - Self::new(editor) - } -} - -struct MeshBuilder { - vertices: Vec, - indices: Vec, -} - -impl MeshBuilder { - fn new() -> Self { - Self { - vertices: Vec::new(), - indices: Vec::new(), - } - } - - fn append( - &mut self, - triangulation: Triangulation, usize>, - color: color::Packed, - ) -> &mut Self { - let offset = self.vertices.len(); - for p in triangulation.points.iter() { - self.vertices.push(SolidVertex2D { - position: [p.x, p.y], - color, - }); } - self.indices - .extend(triangulation.indices.iter().map(|&i| (i + offset) as u32)); - self + Self { shapes } } - fn build(self) -> Indexed { - Indexed { - vertices: self.vertices, - indices: self.indices, + pub(crate) fn paint(self, painter: &Painter) { + for mut shape in self.shapes { + shape.translate(painter.clip_rect().min.to_vec2()); + painter.add(shape); } } } diff --git a/examples/overlay_editor/src/geom/camera.rs b/examples/overlay_editor/src/geom/camera.rs index d84a826e..9cd9e715 100644 --- a/examples/overlay_editor/src/geom/camera.rs +++ b/examples/overlay_editor/src/geom/camera.rs @@ -1,13 +1,13 @@ +use eframe::egui::Vec2; use i_triangle::i_overlay::i_float::int::point::IntPoint; use i_triangle::i_overlay::i_float::int::rect::IntRect; -use iced::{Size, Vector}; #[derive(Debug, Clone, Copy)] pub(crate) struct Camera { pub(crate) scale: f32, pub(crate) i_scale: f32, - pub(crate) size: Size, - pub(crate) pos: Vector, + pub(crate) size: Vec2, + pub(crate) pos: Vec2, } impl Camera { @@ -15,8 +15,8 @@ impl Camera { Self { scale: 0.0, i_scale: 0.0, - size: Size::ZERO, - pos: Vector::new(0.0, 0.0), + size: Vec2::ZERO, + pos: Vec2::new(0.0, 0.0), } } @@ -25,28 +25,22 @@ impl Camera { } pub(crate) fn set_scale(&mut self, scale: f32) { - self.scale = scale; - self.i_scale = 1.0 / scale; + self.scale = scale.clamp(1e-10, 1e8); + self.i_scale = 1.0 / self.scale; } pub(crate) fn is_not_empty(&self) -> bool { self.scale > 0.0 } - pub(crate) fn new(rect: IntRect, size: Size) -> Self { - let w_pow = rect.width().max(1).ilog2() as usize; - let h_pow = rect.height().max(1).ilog2() as usize; - - let width = (1 << w_pow) as f32; - let height = (1 << h_pow) as f32; - let sw = size.width / width; - let sh = size.height / height; - - let scale = 0.25 * sw.min(sh); + pub(crate) fn new(rect: IntRect, size: Vec2) -> Self { + let width = (rect.max_x as f64 - rect.min_x as f64).max(1.0) as f32; + let height = (rect.max_y as f64 - rect.min_y as f64).max(1.0) as f32; + let scale = (0.5 * (size.x / width).min(size.y / height)).max(1e-10); let i_scale = 1.0 / scale; - let x = 0.5 * (rect.min_x + rect.max_x) as f32; - let y = 0.5 * (rect.min_y + rect.max_y) as f32; - let pos = Vector::new(x, y); + let x = (0.5 * (rect.min_x as f64 + rect.max_x as f64)) as f32; + let y = (0.5 * (rect.min_y as f64 + rect.max_y as f64)) as f32; + let pos = Vec2::new(x, y); Camera { scale, @@ -57,50 +51,37 @@ impl Camera { } #[inline] - pub(crate) fn world_to_screen( - &self, - view_left_top: Vector, - world: IntPoint, - ) -> Vector { - let x = - self.scale * (world.x as f32 - self.pos.x) + view_left_top.x + 0.5 * self.size.width; - let y = - self.scale * (self.pos.y - world.y as f32) + view_left_top.y + 0.5 * self.size.height; - Vector { x, y } - } - - #[inline] - pub(crate) fn int_world_to_view(&self, world: IntPoint) -> Vector { - self.world_to_view(Vector::new(world.x as f32, world.y as f32)) + pub(crate) fn int_world_to_view(&self, world: IntPoint) -> Vec2 { + self.world_to_view(Vec2::new(world.x as f32, world.y as f32)) } #[inline] - pub(crate) fn world_to_view(&self, world: Vector) -> Vector { - let x = self.scale * (world.x - self.pos.x) + 0.5 * self.size.width; - let y = self.scale * (self.pos.y - world.y) + 0.5 * self.size.height; - Vector { x, y } + pub(crate) fn world_to_view(&self, world: Vec2) -> Vec2 { + let x = self.scale * (world.x - self.pos.x) + 0.5 * self.size.x; + let y = self.scale * (self.pos.y - world.y) + 0.5 * self.size.y; + Vec2 { x, y } } #[inline] - pub(crate) fn view_to_world(&self, view: Vector) -> Vector { - let x = self.i_scale * (view.x - 0.5 * self.size.width) + self.pos.x; - let y = self.i_scale * (0.5 * self.size.height - view.y) + self.pos.y; - Vector { x, y } + pub(crate) fn view_to_world(&self, view: Vec2) -> Vec2 { + let x = self.i_scale * (view.x - 0.5 * self.size.x) + self.pos.x; + let y = self.i_scale * (0.5 * self.size.y - view.y) + self.pos.y; + Vec2 { x, y } } #[inline] - pub(crate) fn view_distance_to_world(&self, view_distance: Vector) -> Vector { + pub(crate) fn view_distance_to_world(&self, view_distance: Vec2) -> Vec2 { let x = view_distance.x * self.i_scale; let y = -view_distance.y * self.i_scale; - Vector { x, y } + Vec2 { x, y } } } #[cfg(test)] mod tests { use super::Camera; + use eframe::egui::Vec2; use i_triangle::i_overlay::i_float::int::rect::IntRect; - use iced::Size; #[test] fn camera_supports_degenerate_bounds() { @@ -111,7 +92,7 @@ mod tests { ]; for rect in rects { - let camera = Camera::new(rect, Size::new(800.0, 600.0)); + let camera = Camera::new(rect, Vec2::new(800.0, 600.0)); assert!(camera.scale.is_finite()); assert!(camera.scale > 0.0); assert!(camera.i_scale.is_finite()); diff --git a/examples/overlay_editor/src/geom/vector.rs b/examples/overlay_editor/src/geom/vector.rs index 4051b5ea..323d2ff5 100644 --- a/examples/overlay_editor/src/geom/vector.rs +++ b/examples/overlay_editor/src/geom/vector.rs @@ -1,19 +1,6 @@ +use eframe::egui::Vec2; use i_triangle::i_overlay::i_float::int::point::IntPoint; -use iced::{Point, Vector}; -pub(crate) trait VectorExt { - fn round(&self) -> IntPoint; - fn point(point: Point) -> Self; -} - -impl VectorExt for Vector { - fn round(&self) -> IntPoint { - IntPoint::new(self.x.round() as i32, self.y.round() as i32) - } - fn point(point: Point) -> Self { - Self { - x: point.x, - y: point.y, - } - } +pub(crate) fn round_to_int(value: Vec2) -> IntPoint { + IntPoint::new(value.x.round() as i32, value.y.round() as i32) } diff --git a/examples/overlay_editor/src/lib.rs b/examples/overlay_editor/src/lib.rs index a72f900b..0aa7fe13 100644 --- a/examples/overlay_editor/src/lib.rs +++ b/examples/overlay_editor/src/lib.rs @@ -4,4 +4,28 @@ mod draw; mod geom; mod point_editor; mod sheet; +#[cfg(target_arch = "wasm32")] pub mod web; + +#[cfg(not(target_arch = "wasm32"))] +pub fn run_desktop() -> eframe::Result { + let resources = data::resource::AppResource::with_paths( + concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/boolean"), + concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/string"), + concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/stroke"), + concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/variable_stroke"), + concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/outline"), + ); + eframe::run_native( + "iOverlay Editor", + eframe::NativeOptions { + viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1280.0, 800.0]), + centered: true, + ..Default::default() + }, + Box::new(move |cc| { + cc.egui_ctx.set_visuals(eframe::egui::Visuals::dark()); + Ok(Box::new(app::main::EditorApp::with_resource(resources))) + }), + ) +} diff --git a/examples/overlay_editor/src/main.rs b/examples/overlay_editor/src/main.rs index 3ad77010..bbbecbf9 100644 --- a/examples/overlay_editor/src/main.rs +++ b/examples/overlay_editor/src/main.rs @@ -1,37 +1,7 @@ -mod app; -mod data; -mod draw; -mod geom; -mod point_editor; -mod sheet; - -use crate::app::main::EditorApp; -use crate::data::resource::AppResource; -use iced::application; - #[cfg(not(target_arch = "wasm32"))] -fn main() -> iced::Result { - run_desktop() +fn main() -> eframe::Result { + overlay_editor::run_desktop() } -#[cfg(not(target_arch = "wasm32"))] -fn run_desktop() -> iced::Result { - let app_initializer = move || { - let app_resource = AppResource::with_paths( - "../tests/boolean", - "../tests/string", - "../tests/stroke", - "../tests/variable_stroke", - "../tests/outline", - ); - let app = EditorApp::with_resource(app_resource); - (app, iced::Task::none()) - }; - - application(app_initializer, EditorApp::update, EditorApp::view) - .resizable(true) - .centered() - .title("iOverlay Editor") - .subscription(EditorApp::subscription) - .run() -} +#[cfg(target_arch = "wasm32")] +fn main() {} diff --git a/examples/overlay_editor/src/point_editor/state.rs b/examples/overlay_editor/src/point_editor/state.rs index 83434033..9a0d3980 100644 --- a/examples/overlay_editor/src/point_editor/state.rs +++ b/examples/overlay_editor/src/point_editor/state.rs @@ -1,272 +1,72 @@ -use crate::point_editor::point::EditorPoint; -use iced::advanced::graphics::color::pack; -use iced::{Color, Rectangle, Transformation, Vector}; - -use crate::geom::camera::Camera; -use crate::geom::vector::VectorExt; -use crate::point_editor::widget::{PointEditUpdate, PointsEditorWidget}; -use iced::advanced::graphics::mesh::{Indexed, SolidVertex2D}; -use iced::advanced::graphics::Mesh; - -#[derive(Clone)] -pub(super) struct MeshCache { - radius: f32, - pub(super) main: Mesh, - pub(super) drag: Mesh, - pub(super) hover: Mesh, -} - -pub(super) enum SelectState { - Hover(usize), - Drag(Drag), - None, -} - -pub(super) struct Drag { - pub(super) index: usize, - editor_point: EditorPoint, - start_float: Vector, +use crate::{ + geom::{camera::Camera, vector::round_to_int}, + point_editor::{point::EditorPoint, widget::PointEditUpdate}, +}; +use eframe::egui::Vec2; + +pub(crate) struct Drag { + index: usize, + point: EditorPoint, + start: Vec2, } +#[derive(Default)] pub(crate) struct PointsEditorState { - pub(super) mesh_cache: Option, - pub(super) select: SelectState, + pub(crate) hover: Option, + drag: Option, } impl PointsEditorState { - pub(crate) fn update_mesh( - &mut self, - r: f32, - main_color: Color, - hover_color: Color, - drag_color: Color, - ) { - let radius = if let Some(cache) = &self.mesh_cache { - cache.radius - } else { - 0.0 - }; - - if (radius - r).abs() < 0.1 { - return; - } - - let sr = 1.2 * r; - - let mut main_vertices = Vec::with_capacity(4); - let mut hover_vertices = Vec::with_capacity(4); - let mut drag_vertices = Vec::with_capacity(4); - let mut indices = Vec::with_capacity(6); - let main_pack = pack(main_color); - let hover_pack = pack(hover_color); - let drag_pack = pack(drag_color); - - main_vertices.push(SolidVertex2D { - position: [0.0, r], - color: main_pack, - }); - main_vertices.push(SolidVertex2D { - position: [r, 2.0 * r], - color: main_pack, - }); - main_vertices.push(SolidVertex2D { - position: [2.0 * r, r], - color: main_pack, - }); - main_vertices.push(SolidVertex2D { - position: [r, 0.0], - color: main_pack, - }); - - hover_vertices.push(SolidVertex2D { - position: [0.0, sr], - color: hover_pack, - }); - hover_vertices.push(SolidVertex2D { - position: [r, 2.0 * sr], - color: hover_pack, - }); - hover_vertices.push(SolidVertex2D { - position: [2.0 * sr, sr], - color: hover_pack, - }); - hover_vertices.push(SolidVertex2D { - position: [sr, 0.0], - color: hover_pack, - }); - - drag_vertices.push(SolidVertex2D { - position: [0.0, r], - color: drag_pack, - }); - drag_vertices.push(SolidVertex2D { - position: [r, 2.0 * r], - color: drag_pack, - }); - drag_vertices.push(SolidVertex2D { - position: [2.0 * r, r], - color: drag_pack, - }); - drag_vertices.push(SolidVertex2D { - position: [r, 0.0], - color: drag_pack, - }); - - indices.push(0); - indices.push(1); - indices.push(2); - - indices.push(0); - indices.push(2); - indices.push(3); - - self.mesh_cache = Some(MeshCache { - radius: r, - main: Mesh::Solid { - buffers: Indexed { - vertices: main_vertices, - indices: indices.clone(), - }, - transformation: Transformation::IDENTITY, - clip_bounds: Rectangle::INFINITE, - }, - hover: Mesh::Solid { - buffers: Indexed { - vertices: hover_vertices, - indices: indices.clone(), - }, - transformation: Transformation::translate(r - sr, r - sr), - clip_bounds: Rectangle::INFINITE, - }, - drag: Mesh::Solid { - buffers: Indexed { - vertices: drag_vertices, - indices, - }, - transformation: Transformation::IDENTITY, - clip_bounds: Rectangle::INFINITE, - }, - }); + pub(crate) fn is_dragging(&self) -> bool { + self.drag.is_some() } - - pub(super) fn mouse_press( - &mut self, - widget: &PointsEditorWidget, - cursor: Vector, - ) -> bool { - let mut min_ds = widget.hover_radius * widget.hover_radius; - let mut min_index = usize::MAX; - // println!("cursor: {:?}", &cursor); - for (i, p) in widget.points.iter().enumerate() { - let view_pos = widget.camera.int_world_to_view(p.pos); - // println!("screen_pos: {:?}", &screen_pos); - let ds = Self::sqr_length(&cursor, &view_pos); - if ds <= min_ds { - min_ds = ds; - min_index = i; + pub(crate) fn selected(&self) -> Option { + self.drag.as_ref().map(|d| d.index).or(self.hover) + } + pub(crate) fn hover(&mut self, camera: Camera, points: &[EditorPoint], cursor: Vec2) { + let mut distance = 12.0_f32.powi(2); + self.hover = None; + for (index, point) in points.iter().enumerate() { + let d = (camera.int_world_to_view(point.pos) - cursor).length_sq(); + if d <= distance { + distance = d; + self.hover = Some(index); } } - - let is_catch = min_index != usize::MAX; - - if is_catch { - self.select = SelectState::Drag(Drag { - index: min_index, - start_float: cursor, - editor_point: widget.points[min_index].clone(), - }); - } - - is_catch } - - pub(super) fn mouse_release( - &mut self, - widget: &PointsEditorWidget, - cursor: Vector, - ) -> bool { - if let SelectState::Drag(_) = &self.select { - self.select = SelectState::None; - self.mouse_hover(widget.camera, widget.hover_radius, widget.points, cursor); - true - } else { - false - } + pub(crate) fn press(&mut self, camera: Camera, points: &[EditorPoint], cursor: Vec2) -> bool { + self.hover(camera, points, cursor); + self.drag = self.hover.map(|index| Drag { + index, + point: points[index].clone(), + start: cursor, + }); + self.is_dragging() } - - pub(super) fn mouse_move( - &mut self, - widget: &PointsEditorWidget, - cursor: Vector, - ) -> Option { - if let SelectState::Drag(drag) = &self.select { - Self::mouse_drag(drag, widget.camera, widget.points, cursor) - } else { - self.mouse_hover(widget.camera, widget.hover_radius, widget.points, cursor); - None - } + pub(crate) fn release(&mut self) { + self.drag = None; } - - fn mouse_drag( - drag: &Drag, + pub(crate) fn drag( + &self, camera: Camera, points: &[EditorPoint], - cursor: Vector, + cursor: Vec2, ) -> Option { - let translate = cursor - drag.start_float; - let world_dist = camera.view_distance_to_world(translate).round(); - let world_point = world_dist + drag.editor_point.pos; - let real_point = &points[drag.index]; - if world_point != real_point.pos { - return Some(PointEditUpdate { - index: drag.index, - point: EditorPoint { - pos: world_point, - index: drag.editor_point.index.clone(), - }, - }); - } - - None - } - - fn mouse_hover( - &mut self, - camera: Camera, - radius: f32, - points: &[EditorPoint], - cursor: Vector, - ) { - let mut min_ds = radius * radius; - let mut min_index = usize::MAX; - for (i, p) in points.iter().enumerate() { - let view_pos = camera.int_world_to_view(p.pos); - let ds = Self::sqr_length(&cursor, &view_pos); - if ds <= min_ds { - min_ds = ds; - min_index = i; - } - } - - if min_index == usize::MAX { - self.select = SelectState::None; - } else { - self.select = SelectState::Hover(min_index); - } - } - - fn sqr_length(a: &Vector, b: &Vector) -> f32 { - let dx = a.x - b.x; - let dy = a.y - b.y; - dx * dx + dy * dy - } -} - -impl Default for PointsEditorState { - fn default() -> Self { - Self { - select: SelectState::None, - mesh_cache: None, + let drag = self.drag.as_ref()?; + let delta = round_to_int(camera.view_distance_to_world(cursor - drag.start)); + let pos = i_triangle::i_overlay::i_float::int::point::IntPoint::new( + drag.point.pos.x.saturating_add(delta.x), + drag.point.pos.y.saturating_add(delta.y), + ); + if points.get(drag.index)?.pos == pos { + return None; } + Some(PointEditUpdate { + index: drag.index, + point: EditorPoint { + pos, + index: drag.point.index.clone(), + }, + }) } } diff --git a/examples/overlay_editor/src/point_editor/widget.rs b/examples/overlay_editor/src/point_editor/widget.rs index 374fc64c..3e6ab1fa 100644 --- a/examples/overlay_editor/src/point_editor/widget.rs +++ b/examples/overlay_editor/src/point_editor/widget.rs @@ -1,218 +1,46 @@ -use crate::geom::camera::Camera; -use crate::point_editor::point::EditorPoint; -use crate::point_editor::state::PointsEditorState; -use crate::point_editor::state::SelectState; -use iced::advanced::layout::{self, Layout}; -use iced::advanced::widget::tree; -use iced::advanced::widget::tree::State; -use iced::advanced::widget::{Tree, Widget}; -use iced::advanced::{renderer, Clipboard, Shell}; -use iced::{mouse, Color, Event, Point}; -use iced::{Element, Length, Rectangle, Renderer, Size, Theme}; +use crate::{ + app::design::Design, + geom::camera::Camera, + point_editor::{point::EditorPoint, state::PointsEditorState}, +}; +use eframe::egui::{Painter, Shape, Stroke, Vec2}; #[derive(Debug, Clone)] pub(crate) struct PointEditUpdate { - pub(crate) point: EditorPoint, pub(crate) index: usize, + pub(crate) point: EditorPoint, } -pub(crate) struct PointsEditorWidget<'a, Message> { - pub(super) points: &'a Vec, - pub(super) camera: Camera, - main_color: Color, - drag_color: Color, - hover_color: Color, - pub(super) mesh_radius: f32, - pub(super) hover_radius: f32, - on_update: Box Message + 'a>, -} +pub(crate) struct PointsEditorWidget; -impl<'a, Message> PointsEditorWidget<'a, Message> { - pub(crate) fn new( - points: &'a Vec, +impl PointsEditorWidget { + pub(crate) fn paint( + painter: &Painter, camera: Camera, - on_update: impl Fn(PointEditUpdate) -> Message + 'a, - ) -> Self { - let binding = Theme::Dark; - let palette = binding.extended_palette(); - - let (main_color, hover_color, drag_color) = if palette.is_dark { - ( - Color::WHITE, - palette.primary.base.color, - palette.primary.weak.color, - ) - } else { - ( - Color::BLACK, - palette.primary.base.color, - palette.primary.weak.color, - ) - }; - - Self { - points, - camera, - mesh_radius: 4.0, - hover_radius: 12.0, - main_color, - hover_color, - drag_color, - on_update: Box::new(on_update), - } - } - - pub(crate) fn set_hover_color(mut self, color: Color) -> Self { - self.hover_color = color; - self - } - - pub(crate) fn set_drag_color(mut self, color: Color) -> Self { - self.drag_color = color; - self - } -} - -impl Widget for PointsEditorWidget<'_, Message> { - fn tag(&self) -> tree::Tag { - tree::Tag::of::() - } - - fn state(&self) -> State { - State::new(PointsEditorState::default()) - } - - fn size(&self) -> Size { - Size { - width: Length::Fill, - height: Length::Fill, - } - } - - fn layout( - &mut self, - tree: &mut Tree, - _renderer: &Renderer, - limits: &layout::Limits, - ) -> layout::Node { - if let State::Some(state_box) = &mut tree.state { - state_box - .downcast_mut::() - .unwrap() - .update_mesh( - self.mesh_radius, - self.main_color, - self.hover_color, - self.drag_color, - ) - }; - - layout::Node::new(limits.max()) - } - - fn update( - &mut self, - tree: &mut Tree, - event: &Event, - layout: Layout<'_>, - cursor: mouse::Cursor, - _renderer: &Renderer, - _clipboard: &mut dyn Clipboard, - shell: &mut Shell<'_, Message>, - _viewport: &Rectangle, + points: &[EditorPoint], + state: &PointsEditorState, ) { - let bounds = layout.bounds(); - - let mouse_event = if let Event::Mouse(mouse_event) = event { - mouse_event - } else { - return; - }; - - let state = tree.state.downcast_mut::(); - match mouse_event { - mouse::Event::CursorMoved { position } => { - if bounds.contains(*position) { - let view_cursor = *position - bounds.position(); - if let Some(updated_point) = state.mouse_move(&*self, view_cursor) { - shell.publish((self.on_update)(updated_point)); - shell.capture_event(); - } - } - } - mouse::Event::ButtonPressed(mouse::Button::Left) => { - let position = cursor.position().unwrap_or(Point::ORIGIN); - if bounds.contains(position) { - let view_cursor = position - bounds.position(); - if state.mouse_press(&*self, view_cursor) { - shell.capture_event(); - } - } - } - mouse::Event::ButtonReleased(mouse::Button::Left) => { - let position = cursor.position().unwrap_or(Point::ORIGIN); - let view_cursor = position - bounds.position(); - if state.mouse_release(&*self, view_cursor) { - shell.capture_event(); - } - } - _ => {} + for (index, point) in points.iter().enumerate() { + let selected = state.selected() == Some(index); + let color = if selected && state.is_dragging() { + Design::accent_color() + } else if selected { + Design::negative_color() + } else { + Design::subject_color() + }; + let radius = if selected { 5.0 } else { 4.0 }; + let p = painter.clip_rect().min + camera.int_world_to_view(point.pos); + painter.add(Shape::convex_polygon( + vec![ + p + Vec2::new(-radius, 0.0), + p + Vec2::new(0.0, -radius), + p + Vec2::new(radius, 0.0), + p + Vec2::new(0.0, radius), + ], + color, + Stroke::NONE, + )); } } - - fn draw( - &self, - tree: &Tree, - renderer: &mut Renderer, - _theme: &Theme, - _style: &renderer::Style, - layout: Layout<'_>, - _cursor: mouse::Cursor, - _viewport: &Rectangle, - ) { - let state = tree.state.downcast_ref::(); - - let mesh = if let Some(mesh) = &state.mesh_cache { - mesh - } else { - return; - }; - - use iced::advanced::graphics::mesh::Renderer as _; - use iced::advanced::Renderer as _; - - let bounds = layout.bounds(); - renderer.with_layer(bounds, |renderer| { - let offset = layout.position() - Point::new(self.mesh_radius, self.mesh_radius); - - for (index, p) in self.points.iter().enumerate() { - let position = self.camera.world_to_screen(offset, p.pos); - let mesh = match &state.select { - SelectState::Hover(hover_index) => { - if index == *hover_index { - mesh.hover.clone() - } else { - mesh.main.clone() - } - } - SelectState::Drag(drag) => { - if index == drag.index { - mesh.drag.clone() - } else { - mesh.main.clone() - } - } - SelectState::None => mesh.main.clone(), - }; - - renderer.with_translation(position, |renderer| renderer.draw_mesh(mesh)); - } - }); - } -} - -impl<'a, Message: 'a> From> for Element<'a, Message> { - fn from(editor: PointsEditorWidget<'a, Message>) -> Self { - Self::new(editor) - } } diff --git a/examples/overlay_editor/src/sheet/mod.rs b/examples/overlay_editor/src/sheet/mod.rs index a73631c1..f5d327d0 100644 --- a/examples/overlay_editor/src/sheet/mod.rs +++ b/examples/overlay_editor/src/sheet/mod.rs @@ -1,2 +1,2 @@ -mod state; +pub(crate) mod state; pub(crate) mod widget; diff --git a/examples/overlay_editor/src/sheet/state.rs b/examples/overlay_editor/src/sheet/state.rs index d223d5b4..b3f3c31e 100644 --- a/examples/overlay_editor/src/sheet/state.rs +++ b/examples/overlay_editor/src/sheet/state.rs @@ -1,82 +1,42 @@ use crate::geom::camera::Camera; -use iced::mouse::ScrollDelta; -use iced::{Size, Vector}; +use eframe::egui::Vec2; -struct Drag { - start_screen: Vector, - start_world: Vector, -} - -enum DragState { - Drag(Drag), - None, -} - -pub(super) struct SheetState { - drag_state: DragState, +#[derive(Default)] +pub(crate) struct SheetState { + drag: Option<(Vec2, Vec2)>, } impl SheetState { - pub(super) fn mouse_press(&mut self, camera: Camera, view_cursor: Vector) { - self.drag_state = DragState::Drag(Drag { - start_screen: view_cursor, - start_world: camera.pos, - }); + pub(crate) fn press(&mut self, camera: Camera, cursor: Vec2) { + self.drag = Some((cursor, camera.pos)); } - - pub(super) fn mouse_release(&mut self) { - self.drag_state = DragState::None; + pub(crate) fn release(&mut self) { + self.drag = None; } - - pub(super) fn mouse_move( - &mut self, - camera: Camera, - view_cursor: Vector, - ) -> Option> { - if let DragState::Drag(drag) = &self.drag_state { - let translate = drag.start_screen - view_cursor; - let world_dist = camera.view_distance_to_world(translate); - let new_pos = Vector::new( - drag.start_world.x + world_dist.x, - drag.start_world.y + world_dist.y, - ); - Some(new_pos) - } else { - None + pub(crate) fn drag(&self, camera: &mut Camera, cursor: Vec2) { + if let Some((start, pos)) = self.drag { + camera.pos = pos + camera.view_distance_to_world(start - cursor); } } - - pub(super) fn mouse_wheel_scrolled( - &mut self, - camera: Camera, - viewport_size: Size, - delta: ScrollDelta, - view_cursor: Vector, - ) -> Option { - if let ScrollDelta::Pixels { x: _, y } = delta { - let s = 1.0 + y / viewport_size.height; - let mut new_camera = camera; - new_camera.set_scale(s * camera.scale); - - let world_pos = camera.view_to_world(view_cursor); - let new_view_pos = new_camera.world_to_view(world_pos); - - let view_distance = view_cursor - new_view_pos; - let world_distance = new_camera.view_distance_to_world(view_distance); - - new_camera.pos = new_camera.pos - world_distance; - - Some(new_camera) - } else { - None - } + pub(crate) fn zoom(camera: &mut Camera, cursor: Vec2, delta: f32) { + let world = camera.view_to_world(cursor); + camera.set_scale(camera.scale * (delta * 0.002).exp()); + camera.pos += world - camera.view_to_world(cursor); } } -impl Default for SheetState { - fn default() -> Self { - Self { - drag_state: DragState::None, - } +#[cfg(test)] +mod tests { + use super::*; + use i_triangle::i_overlay::i_float::int::rect::IntRect; + #[test] + fn zoom_preserves_world_position_under_pointer() { + let mut camera = Camera::new(IntRect::new(-100, 100, -100, 100), Vec2::new(800.0, 600.0)); + let cursor = Vec2::new(137.0, 251.0); + let before = camera.view_to_world(cursor); + SheetState::zoom(&mut camera, cursor, 120.0); + assert!((camera.view_to_world(cursor) - before).length() < 1e-4); + SheetState::zoom(&mut camera, cursor, -1e6); + assert!(camera.scale > 0.0 && camera.i_scale.is_finite()); } } diff --git a/examples/overlay_editor/src/sheet/widget.rs b/examples/overlay_editor/src/sheet/widget.rs index d63d7f89..3bf774ee 100644 --- a/examples/overlay_editor/src/sheet/widget.rs +++ b/examples/overlay_editor/src/sheet/widget.rs @@ -1,257 +1,169 @@ -use crate::geom::camera::Camera; -use crate::sheet::state::SheetState; -use iced::advanced::graphics::color::pack; -use iced::advanced::graphics::mesh::{Indexed, SolidVertex2D}; -use iced::advanced::graphics::Mesh; -use iced::advanced::layout::{self, Layout}; -use iced::advanced::widget::tree; -use iced::advanced::widget::{Tree, Widget}; -use iced::advanced::{renderer, Clipboard, Shell}; -use iced::{mouse, Event}; -use iced::{Color, Point, Transformation}; -use iced::{Element, Length, Rectangle, Renderer, Size, Theme, Vector}; - -pub(crate) struct SheetWidget<'a, Message> { - camera: Camera, - grid_color: Color, - on_size: Box Message + 'a>, - on_zoom: Box Message + 'a>, - on_drag: Box) -> Message + 'a>, -} - -impl<'a, Message: 'a> SheetWidget<'a, Message> { - pub(crate) fn new( - camera: Camera, - grid_color: Color, - on_size: impl Fn(Size) -> Message + 'a, - on_zoom: impl Fn(Camera) -> Message + 'a, - on_drag: impl Fn(Vector) -> Message + 'a, - ) -> Self { - Self { - camera, - grid_color, - on_size: Box::new(on_size), - on_zoom: Box::new(on_zoom), - on_drag: Box::new(on_drag), - } - } - - pub(super) fn is_size_changed(&self, size: Size) -> bool { - let w = (size.width - self.camera.size.width).abs(); - let h = (size.height - self.camera.size.height).abs(); - w > 0.01 || h > 0.01 - } - - fn line_mesh(&self, min_x: f32, min_y: f32, max_x: f32, max_y: f32, opacity: f32) -> Mesh { - let color_pack = pack(self.grid_color.scale_alpha(opacity)); - let mut vertices = Vec::with_capacity(4); - let mut indices = Vec::with_capacity(6); - - vertices.push(SolidVertex2D { - position: [min_x, min_y], - color: color_pack, - }); - vertices.push(SolidVertex2D { - position: [min_x, max_y], - color: color_pack, - }); - vertices.push(SolidVertex2D { - position: [max_x, max_y], - color: color_pack, - }); - vertices.push(SolidVertex2D { - position: [max_x, min_y], - color: color_pack, +use crate::{ + app::design::Design, + geom::camera::Camera, + point_editor::{point::EditorPoint, state::PointsEditorState, widget::PointEditUpdate}, + sheet::state::SheetState, +}; +use eframe::egui::{self, Color32, CursorIcon, Painter, Sense, Stroke, Vec2}; + +pub(crate) struct SheetWidget; + +impl SheetWidget { + pub(crate) fn show( + ui: &mut egui::Ui, + camera: &mut Camera, + points: &[EditorPoint], + sheet: &mut SheetState, + editor: &mut PointsEditorState, + ) -> (Painter, Option) { + let (response, painter) = ui.allocate_painter( + ui.available_size().max(Vec2::splat(1.0)), + Sense::CLICK | Sense::DRAG, + ); + painter.rect_filled(response.rect, 0.0, Color32::from_rgb(18, 18, 18)); + let mut update = None; + let (cursor, pressed, down, released, scroll) = ui.input(|i| { + ( + i.pointer.interact_pos(), + i.pointer.primary_pressed(), + i.pointer.primary_down(), + i.pointer.primary_released(), + i.smooth_scroll_delta.y, + ) }); - - indices.push(0); - indices.push(1); - indices.push(2); - - indices.push(0); - indices.push(2); - indices.push(3); - - Mesh::Solid { - buffers: Indexed { vertices, indices }, - transformation: Transformation::IDENTITY, - clip_bounds: Rectangle::INFINITE, - } - } -} - -impl Widget for SheetWidget<'_, Message> { - fn tag(&self) -> tree::Tag { - tree::Tag::of::() - } - - fn state(&self) -> tree::State { - tree::State::new(SheetState::default()) - } - - fn size(&self) -> Size { - Size { - width: Length::Fill, - height: Length::Fill, - } - } - - fn layout( - &mut self, - _tree: &mut Tree, - _renderer: &Renderer, - limits: &layout::Limits, - ) -> layout::Node { - layout::Node::new(limits.max()) - } - - fn update( - &mut self, - tree: &mut Tree, - event: &Event, - layout: Layout<'_>, - cursor: mouse::Cursor, - _renderer: &Renderer, - _clipboard: &mut dyn Clipboard, - shell: &mut Shell<'_, Message>, - _viewport: &Rectangle, - ) { - let bounds = layout.bounds(); - let size = bounds.size(); - if self.is_size_changed(size) { - shell.publish((self.on_size)(size)); - } - - let mouse_event = if let Event::Mouse(mouse_event) = event { - mouse_event - } else { - return; - }; - let state = tree.state.downcast_mut::(); - match mouse_event { - mouse::Event::CursorMoved { position } => { - if bounds.contains(*position) { - let view_cursor = *position - bounds.position(); - if let Some(drag) = state.mouse_move(self.camera, view_cursor) { - shell.publish((self.on_drag)(drag)); - shell.capture_event(); - return; - } + if let Some(pos) = cursor { + let local = pos - response.rect.min; + if response.hovered() { + editor.hover(*camera, points, local); + if pressed && !editor.press(*camera, points, local) { + sheet.press(*camera, local); } - } - mouse::Event::ButtonPressed(mouse::Button::Left) => { - let position = cursor.position().unwrap_or(Point::ORIGIN); - if bounds.contains(position) { - let view_cursor = position - bounds.position(); - state.mouse_press(self.camera, view_cursor); - shell.capture_event(); - return; + if scroll != 0.0 && !down { + SheetState::zoom(camera, local, scroll); } + } else { + editor.hover = None; } - mouse::Event::ButtonReleased(mouse::Button::Left) => { - state.mouse_release(); - shell.capture_event(); - return; - } - mouse::Event::WheelScrolled { delta } => { - let position = cursor.position().unwrap_or(Point::ORIGIN); - if bounds.contains(position) { - let cursor = position - bounds.position(); - if let Some(scale) = - state.mouse_wheel_scrolled(self.camera, bounds.size(), *delta, cursor) - { - shell.publish((self.on_zoom)(scale)); - shell.capture_event(); - return; - } + if down || released { + if editor.is_dragging() { + update = editor.drag(*camera, points, local); + } else { + sheet.drag(camera, local); } } - _ => { - // println!("other mouse event: {:?}", mouse_event); + if response.hovered() || editor.is_dragging() { + response.on_hover_cursor(if editor.selected().is_some() { + CursorIcon::PointingHand + } else { + CursorIcon::Grab + }); } } + if released || !down { + editor.release(); + sheet.release(); + } + Self::grid(&painter, *camera); + (painter, update) } - fn draw( - &self, - _tree: &Tree, - renderer: &mut Renderer, - _theme: &Theme, - _style: &renderer::Style, - layout: Layout<'_>, - _cursor: mouse::Cursor, - _viewport: &Rectangle, - ) { - const MIN_SCALE: f32 = 20.0; - let scale = self.camera.scale - MIN_SCALE; - if scale <= 0.0 { + fn grid(painter: &Painter, camera: Camera) { + if camera.scale <= 20.0 { return; } - const SCALE_RANGE: f32 = 50.0; - const INVERT_RANGE: f32 = 1.0 / SCALE_RANGE; - - // normalize scale - let s = (scale * INVERT_RANGE).min(1.0); - // stroke radius - let r = 1.0; - - use iced::advanced::graphics::mesh::Renderer as _; - use iced::advanced::Renderer as _; - - let rect = layout.bounds(); - renderer.with_layer(rect, |renderer| { - let view_min = Vector::new(0.0, 0.0); - let view_max = Vector::new(rect.width, rect.height); - - let world_min = self.camera.view_to_world(view_min); - let world_max = self.camera.view_to_world(view_max); - - let round_world_min_x = world_min.x.ceil(); - let round_world_min_y = world_min.y.ceil(); - - let round_world_max_x = world_max.x.trunc(); - let round_world_max_y = world_max.y.trunc(); - - let nfx = (round_world_max_x - round_world_min_x + 0.001) - .round() - .abs(); - let nfy = (round_world_max_y - round_world_min_y + 0.001) - .round() - .abs(); - - let nx = nfx as usize; - let ny = nfy as usize; - - let vr_mesh = self.line_mesh(-r, view_min.y, r, view_max.y, s); - let hz_mesh = self.line_mesh(view_min.x, -r, view_max.x, r, s); - - let round_view_min = self - .camera - .world_to_view(Vector::new(round_world_min_x, round_world_min_y)); - let round_view_max = self - .camera - .world_to_view(Vector::new(round_world_max_x, round_world_max_y)); - - let dx = (round_view_max.x - round_view_min.x) / nfx; - let dy = (round_view_max.y - round_view_min.y) / nfy; - - let mut position = Vector::new(round_view_min.x + rect.x, view_min.y + rect.y); - for _ in 0..=nx { - renderer.with_translation(position, |renderer| renderer.draw_mesh(vr_mesh.clone())); - position.x += dx; - } - - let mut position = Vector::new(view_min.x + rect.x, round_view_min.y + rect.y); - for _ in 0..=ny { - renderer.with_translation(position, |renderer| renderer.draw_mesh(hz_mesh.clone())); - position.y += dy; - } - }); + let alpha = ((camera.scale - 20.0) / 50.0).min(1.0) * 0.5; + let stroke = Stroke::new(1.0_f32, Design::negative_color().gamma_multiply(alpha)); + let rect = painter.clip_rect(); + let top_left = camera.view_to_world(Vec2::ZERO); + let bottom_right = camera.view_to_world(rect.size()); + // Index from the first visible line: never increment large f32 world coordinates by one. + let first_x = camera.world_to_view(Vec2::new(top_left.x.ceil(), 0.0)).x; + let first_y = camera.world_to_view(Vec2::new(0.0, top_left.y.floor())).y; + for i in 0..=((bottom_right.x - top_left.x).abs().ceil() as usize).min(4096) { + let x = rect.left() + first_x + i as f32 * camera.scale; + painter.line_segment( + [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], + stroke, + ); + } + for i in 0..=((top_left.y - bottom_right.y).abs().ceil() as usize).min(4096) { + let y = rect.top() + first_y + i as f32 * camera.scale; + painter.line_segment( + [egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)], + stroke, + ); + } } } -impl<'a, Message: 'a> From> for Element<'a, Message> { - fn from(sheet: SheetWidget<'a, Message>) -> Self { - Self::new(sheet) +#[cfg(test)] +mod tests { + use super::*; + use crate::point_editor::point::MultiIndex; + use i_triangle::i_overlay::i_float::int::{point::IntPoint, rect::IntRect}; + + fn button(pos: egui::Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + } + } + + #[test] + fn pointer_drag_edits_points_without_panning_and_background_drag_pans() { + let ctx = egui::Context::default(); + let mut camera = Camera::new(IntRect::new(-100, 100, -100, 100), Vec2::new(800.0, 600.0)); + let mut points = vec![EditorPoint { + pos: IntPoint::new(0, 0), + index: MultiIndex { + group_index: 0, + path_index: 0, + point_index: 0, + }, + }]; + let mut sheet = SheetState::default(); + let mut editor = PointsEditorState::default(); + let mut frame = |events: Vec| { + let _ = ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + Vec2::new(800.0, 600.0), + )), + events, + ..Default::default() + }, + |ui| { + let (_, update) = + SheetWidget::show(ui, &mut camera, &points, &mut sheet, &mut editor); + if let Some(update) = update { + points[update.index] = update.point; + } + }, + ); + (camera, points[0].pos) + }; + frame(vec![]); + let center = egui::pos2(400.0, 300.0); + frame(vec![egui::Event::PointerMoved(center)]); + frame(vec![button(center, true)]); + let moved = center + Vec2::new(30.0, -15.0); + let (after, point) = frame(vec![egui::Event::PointerMoved(moved)]); + assert_eq!(after.pos, Vec2::ZERO); + assert_eq!(point, IntPoint::new(20, 10)); + frame(vec![button(moved, false)]); + let background = egui::pos2(700.0, 500.0); + frame(vec![egui::Event::PointerMoved(background)]); + frame(vec![button(background, true)]); + let (after, point) = frame(vec![egui::Event::PointerMoved( + background + Vec2::new(30.0, 15.0), + )]); + assert_eq!(after.pos, Vec2::new(-20.0, 10.0)); + assert_eq!(point, IntPoint::new(20, 10)); + frame(vec![button(background + Vec2::new(30.0, 15.0), false)]); + let (after, _) = frame(vec![egui::Event::PointerMoved(background)]); + assert_eq!(after.pos, Vec2::new(-20.0, 10.0)); } } diff --git a/examples/overlay_editor/src/web.rs b/examples/overlay_editor/src/web.rs index 662ae197..41a26ebc 100644 --- a/examples/overlay_editor/src/web.rs +++ b/examples/overlay_editor/src/web.rs @@ -1,64 +1,78 @@ -use std::panic; -use std::sync::Once; +use crate::{app::main::EditorApp, data::resource::AppResource}; use wasm_bindgen::prelude::*; #[wasm_bindgen] -pub struct WebApp {} - -static INIT_LOGGER: Once = Once::new(); +pub struct WebApp { + runner: eframe::WebRunner, +} -#[cfg(target_arch = "wasm32")] #[wasm_bindgen] impl WebApp { #[wasm_bindgen(constructor)] pub fn create() -> Self { - Self {} + console_error_panic_hook::set_once(); + let _ = console_log::init_with_level(log::Level::Debug); + Self { + runner: eframe::WebRunner::new(), + } } - #[wasm_bindgen] - pub fn start( - &mut self, + /// Keeps the existing five JSON arguments. Await the returned Promise to report startup errors. + pub async fn start( + &self, boolean_data: String, string_data: String, stroke_data: String, variable_stroke_data: String, outline_data: String, - ) { - use iced::application; - use log::info; - - use crate::app::main::EditorApp; - use crate::data::resource::AppResource; - - panic::set_hook(Box::new(console_error_panic_hook::hook)); - INIT_LOGGER.call_once(|| { - console_log::init_with_level(log::Level::Debug).expect("error initializing log"); - }); - - info!("wasm start"); - - let app_initializer = move || { - info!("wasm init"); - let app_resource = AppResource::with_content( - &boolean_data, - &string_data, - &stroke_data, - &variable_stroke_data, - &outline_data, - ); - let app = EditorApp::with_resource(app_resource); - - (app, iced::Task::none()) + ) -> Result<(), JsValue> { + let document = web_sys::window() + .and_then(|window| window.document()) + .ok_or_else(|| JsValue::from_str("Browser document is unavailable"))?; + let canvas = match document.get_element_by_id("overlay-editor-canvas") { + Some(element) => element.dyn_into::()?, + None => { + let canvas = document + .create_element("canvas")? + .dyn_into::()?; + canvas.set_id("overlay-editor-canvas"); + canvas.style().set_property("width", "100vw")?; + canvas.style().set_property("height", "100vh")?; + canvas.style().set_property("display", "block")?; + let body = document + .body() + .ok_or_else(|| JsValue::from_str("Browser body is unavailable"))?; + body.style().set_property("margin", "0")?; + body.append_child(&canvas)?; + canvas + } }; + let resource = AppResource::with_content( + &boolean_data, + &string_data, + &stroke_data, + &variable_stroke_data, + &outline_data, + ); + self.runner + .start( + canvas, + eframe::WebOptions::default(), + Box::new(move |cc| { + cc.egui_ctx.set_visuals(eframe::egui::Visuals::dark()); + Ok(Box::new(EditorApp::with_resource(resource))) + }), + ) + .await + } - application(app_initializer, EditorApp::update, EditorApp::view) - .resizable(true) - .centered() - .title("iOverlay Editor") - .subscription(EditorApp::subscription) - .run() - .unwrap(); + pub fn destroy(&self) { + self.runner.destroy(); + } +} - info!("wasm app run"); +impl Default for WebApp { + fn default() -> Self { + Self::create() } } diff --git a/examples/tests/outline/test_8.json b/examples/tests/outline/test_8.json deleted file mode 100644 index 1ff8b013..00000000 --- a/examples/tests/outline/test_8.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "scale": 100.0, - "outline": [ - [ - [53.0, 42.0], - [35.0, 66.0], - [26.0, 75.0], - [27.0, 74.0], - [53.0, 42.0] - ] - ] -} \ No newline at end of file diff --git a/examples/tests/outline/test_9.json b/examples/tests/outline/test_9.json deleted file mode 100644 index 534d4a5b..00000000 --- a/examples/tests/outline/test_9.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "scale": 100.0, - "outline": [ - [ - [0.0, 0.0], - [100.0, 0.0], - [100.0, 100.0], - [0.0, 100.0] - ], - [ - [53.0, 42.0], - [27.0, 74.0], - [26.0, 75.0], - [35.0, 66.0], - [53.0, 42.0] - ] - ] -} \ No newline at end of file From 66d7fc1b33553099ad98ab5333eddb82d2230fbe Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Thu, 17 Sep 2026 20:18:48 +0300 Subject: [PATCH 29/44] miter fix --- iOverlay/README.md | 5 ++ iOverlay/docs/stroke_math.md | 6 ++ iOverlay/src/mesh/float/style.rs | 2 + iOverlay/src/mesh/int/join.rs | 12 ++- iOverlay/src/mesh/int/math/backend.rs | 3 + iOverlay/src/mesh/int/math/float.rs | 1 + iOverlay/src/mesh/int/math/integer.rs | 3 + iOverlay/src/mesh/int/style.rs | 5 +- iOverlay/tests/miter_regression_tests.rs | 107 +++++++++++++++++++++++ 9 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 iOverlay/tests/miter_regression_tests.rs diff --git a/iOverlay/README.md b/iOverlay/README.md index 13ba2e34..4f7a1366 100644 --- a/iOverlay/README.md +++ b/iOverlay/README.md @@ -504,6 +504,11 @@ are supported. Stroke caps can be butt, square, round, or custom; variable-width strokes use round caps and joins. Round geometry uses `ArcOptions`, including configurable CORDIC rotation precision (default: 5). +Integer construction math limits miter joins to a minimum interior angle of +5 degrees, clipping sharper corners. Almost straight corners with interior +angles above 175 degrees use bevel joins to avoid unstable intersections of +rounded offset lines. Float construction math retains its existing angle policy. + Integer distances use input coordinate units without automatic rescaling. Stroke radius is `ceil(max(width, 0) / 2)`; radii at most 1 are degenerate. Use `validate_outline(&style)`, `validate_stroke(&style)`, or diff --git a/iOverlay/docs/stroke_math.md b/iOverlay/docs/stroke_math.md index e1a9a1f5..86b953f7 100644 --- a/iOverlay/docs/stroke_math.md +++ b/iOverlay/docs/stroke_math.md @@ -2,6 +2,12 @@ Both `StrokeStyle` and `IntStrokeStyle` accept `.math(MathMode::Float)` from `i_overlay::mesh::math`. `Integer` remains the default. This prototype selects math for constant-width stroke only; outline and variable-width stroke retain their current integer arithmetic. +Integer miter construction clamps the requested minimum interior angle to at +least 5 degrees and clips sharper corners. Interior angles above 175 degrees +use bevel joins. This uses the turn angle already computed for the join, without +additional normalization or trigonometry. Float mode retains its existing angle +policy, including ordinary miter intersections at nearly straight corners. + ```rust use i_overlay::mesh::float::{stroke::offset::StrokeOffset, style::StrokeStyle}; use i_overlay::mesh::math::MathMode; diff --git a/iOverlay/src/mesh/float/style.rs b/iOverlay/src/mesh/float/style.rs index 0350d230..94da4ec8 100644 --- a/iOverlay/src/mesh/float/style.rs +++ b/iOverlay/src/mesh/float/style.rs @@ -33,6 +33,8 @@ pub enum LineJoin { /// Creates a sharp corner where two lines meet. /// The corner is limited by a miter, where the parameter `Angle` /// is a minimum sharp angle + /// With Integer construction math, the effective minimum is at least + /// 5 degrees, and interior angles above 175 degrees use bevel joins. Miter(T), /// Creates an arc corner where two lines meet. /// The arc is approximated using a group of segments, where the parameter `Angle` diff --git a/iOverlay/src/mesh/int/join.rs b/iOverlay/src/mesh/int/join.rs index cd6cbf9d..f422fc22 100644 --- a/iOverlay/src/mesh/int/join.rs +++ b/iOverlay/src/mesh/int/join.rs @@ -22,11 +22,9 @@ impl> Join { IntLineJoin::Bevel => Self::Bevel, IntLineJoin::Round(options) => Self::Round(M::Arc::new(options)), IntLineJoin::Miter(angle) => { - let min_angle = (1u32 << 31) / 100; + let min_angle = ((1u32 << 31) / 100).max(M::MITER_STABILITY_ANGLE); let max_angle = (1u32 << 31) - 1; - let minimum = angle - .bits() - .clamp(min_angle, max_angle); + let minimum = angle.bits().clamp(min_angle, max_angle); let (sin, cos) = M::sin_cos(Angle::from_bits(minimum / 2)); Self::Miter { minimum, sin, cos } } @@ -93,6 +91,12 @@ impl> Join { } let turn = M::angle_between(incoming, outgoing).bits(); let turn = turn.min(turn.wrapping_neg()); + if turn < M::MITER_STABILITY_ANGLE { + // Almost straight: rounded offset points need not lie on + // intersecting rays near the vertex. Close the gap directly. + segments.push_non_degenerate(a, b); + return; + } if (1u32 << 31) - turn < *minimum { let extension = mul_div::( abs(radius), diff --git a/iOverlay/src/mesh/int/math/backend.rs b/iOverlay/src/mesh/int/math/backend.rs index 03a624d4..66c5ce87 100644 --- a/iOverlay/src/mesh/int/math/backend.rs +++ b/iOverlay/src/mesh/int/math/backend.rs @@ -6,6 +6,9 @@ use i_float::int::{ /// Construction arithmetic only; coordinates and topology remain integer. pub(crate) trait MeshMath: Copy { type Arc: ArcMath>; + /// Minimum interior angle and turn for ordinary miter intersections, in + /// Angle bits. Zero preserves the unrestricted near-straight behavior. + const MITER_STABILITY_ANGLE: u32; fn sin_cos(angle: Angle) -> (i32, i32); fn angle_between(from: UnitIntVector, to: UnitIntVector) -> Angle; diff --git a/iOverlay/src/mesh/int/math/float.rs b/iOverlay/src/mesh/int/math/float.rs index 620fb619..a55ec45c 100644 --- a/iOverlay/src/mesh/int/math/float.rs +++ b/iOverlay/src/mesh/int/math/float.rs @@ -9,6 +9,7 @@ pub(crate) struct FloatMath; impl MeshMath for FloatMath { type Arc = FloatArc; + const MITER_STABILITY_ANGLE: u32 = 0; fn sin_cos(angle: Angle) -> (i32, i32) { angle.sin_cos_with_float() } diff --git a/iOverlay/src/mesh/int/math/integer.rs b/iOverlay/src/mesh/int/math/integer.rs index df7ab3dc..89c40ee6 100644 --- a/iOverlay/src/mesh/int/math/integer.rs +++ b/iOverlay/src/mesh/int/math/integer.rs @@ -9,6 +9,9 @@ pub(crate) struct IntegerMath; impl MeshMath for IntegerMath { type Arc = IntegerArc; + // Five degrees: avoid amplifying direction and grid rounding errors when + // intersecting nearly parallel offset lines. + const MITER_STABILITY_ANGLE: u32 = (1u32 << 31) / 36; #[inline] fn sin_cos(angle: Angle) -> (i32, i32) { angle.sin_cos() diff --git a/iOverlay/src/mesh/int/style.rs b/iOverlay/src/mesh/int/style.rs index 951ef7ea..389e4d7c 100644 --- a/iOverlay/src/mesh/int/style.rs +++ b/iOverlay/src/mesh/int/style.rs @@ -9,7 +9,10 @@ pub enum IntLineJoin { #[default] Bevel, /// Clipped miter, limited by the minimum interior angle. - /// The angle is clamped to 0.01*pi..=0.99*pi. + /// Integer math clamps the minimum interior angle to at least 5 degrees + /// and uses bevel joins for interior angles above 175 degrees. + /// Float math clamps the minimum to 0.01*pi (1.8 degrees), without the + /// near-straight bevel cutoff. Both clamp the maximum below pi. Miter(Angle), /// Rounded join with reusable integer rotation settings. Round(ArcOptions), diff --git a/iOverlay/tests/miter_regression_tests.rs b/iOverlay/tests/miter_regression_tests.rs new file mode 100644 index 00000000..8cdeda06 --- /dev/null +++ b/iOverlay/tests/miter_regression_tests.rs @@ -0,0 +1,107 @@ +use i_float::int::{angle::Angle, point::IntPoint}; +use i_overlay::mesh::int::{ + outline::offset::IntOutlineOffset, + stroke::offset::IntStrokeOffset, + style::{IntLineJoin, IntOutlineStyle, IntStrokeStyle}, +}; +use i_overlay::mesh::math::MathMode; + +#[test] +fn miter_on_nearly_collinear_path_stays_near_input() { + let path = [ + IntPoint::new(-879_382_i32, -1_393), + IntPoint::new(0, 0), + IntPoint::new(7_914_439, 12_537), + ]; + let width = 200_000; + let style = IntStrokeStyle::new(width).line_join(IntLineJoin::Miter(Angle::from_radians(0.1))); + + path.validate_stroke(&style).unwrap(); + let shapes = path.stroke(&style, false).unwrap(); + assert_eq!(shapes.len(), 1); + + // The path is almost straight; a full width of padding is conservative. + // Previously release emitted the spurious point (426_437_379, 7_579_018), + // while debug panicked when constructing the peak. + let x_bounds = path[0].x - width..=path[2].x + width; + let y_bounds = path[0].y - width..=path[2].y + width; + for point in shapes.iter().flatten().flatten() { + assert!( + x_bounds.contains(&point.x) && y_bounds.contains(&point.y), + "miter vertex {point:?} escaped expected bounds: x={x_bounds:?}, y={y_bounds:?}" + ); + } +} + +#[test] +fn nearly_collinear_shrink_outline_stays_inside_input_bounds() { + let contour = [ + IntPoint::new(-879_382_i32, -1_393), + IntPoint::new(0, 0), + IntPoint::new(7_914_439, 12_537), + IntPoint::new(-2_000_000, 6_000_000), + ]; + let style = IntOutlineStyle::new(-100_000).line_join(IntLineJoin::Miter(Angle::from_radians(0.1))); + contour.validate_outline(&style).unwrap(); + let shapes = contour.outline(&style).unwrap(); + assert_eq!(shapes.len(), 1); + for point in shapes.iter().flatten().flatten() { + assert!((-2_000_000..=7_914_439).contains(&point.x)); + assert!((-1_393..=6_000_000).contains(&point.y)); + } +} + +#[test] +fn only_integer_math_bevels_turns_below_five_degrees() { + // Turns on either side of 5 degrees, for both traversal directions. + for (dy, bevel_expected) in [(8_700, true), (8_800, false)] { + let path = [ + IntPoint::new(-100_000_i32, 0), + IntPoint::new(0, 0), + IntPoint::new(100_000, dy), + ]; + for path in [path, [path[2], path[1], path[0]]] { + for math in [MathMode::Integer, MathMode::Float] { + let base = IntStrokeStyle::new(20_000).math(math); + let bevel = path.stroke(&base, false).unwrap(); + let miter = path + .stroke( + &base.line_join(IntLineJoin::Miter(Angle::from_radians(0.1))), + false, + ) + .unwrap(); + assert_eq!( + miter == bevel, + math == MathMode::Integer && bevel_expected, + "dy={dy}, math={math:?}" + ); + } + } + } +} + +#[test] +fn only_integer_math_clips_corners_sharper_than_five_degrees() { + // Interior angle approximately 4.9 degrees; radius 10_000. + // A 5-degree clipped miter reaches at most r / sin(2.5 degrees), + // about 229_256 units. The ordinary 4.9-degree miter reaches over 233_000. + let path = [ + IntPoint::new(-100_000_i32, 0), + IntPoint::new(0, 0), + IntPoint::new(-100_000, 8_573), + ]; + for math in [MathMode::Integer, MathMode::Float] { + let style = IntStrokeStyle::new(20_000) + .math(math) + .line_join(IntLineJoin::Miter(Angle::from_radians(0.01))); + path.validate_stroke(&style).unwrap(); + let shapes = path.stroke(&style, false).unwrap(); + let max_x = shapes.iter().flatten().flatten().map(|p| p.x).max().unwrap(); + assert!(max_x > 200_000, "the sharp corner must retain a miter"); + assert_eq!( + max_x < 230_000, + math == MathMode::Integer, + "math={math:?}, max_x={max_x}" + ); + } +} From 4e400af40ecc6ffa92e4ba7634f17cb7e919d708 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Thu, 17 Sep 2026 20:24:25 +0300 Subject: [PATCH 30/44] fix area calculation --- iOverlay/tests/hierarchy_stress.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/iOverlay/tests/hierarchy_stress.rs b/iOverlay/tests/hierarchy_stress.rs index 4fbfdade..526d0147 100644 --- a/iOverlay/tests/hierarchy_stress.rs +++ b/iOverlay/tests/hierarchy_stress.rs @@ -4,6 +4,7 @@ use i_overlay::core::hierarchy::{ChildLink, FlatShapeHierarchy}; use i_overlay::core::overlay::{ContourDirection, Overlay}; use i_overlay::core::overlay_rule::OverlayRule; use i_shape::int::path::ContourExtension; +use i_shape::int::area::UnsafeArea; use std::panic::{AssertUnwindSafe, catch_unwind}; #[test] @@ -119,7 +120,7 @@ fn assert_hierarchy_matches_containment(hierarchy: &FlatShapeHierarchy, see continue; } - let area = contour.unsafe_area().unsigned_abs(); + let area = contour.iter().copied().unsafe_area().unsigned_abs(); if parent.is_none_or(|candidate| area < candidate.0) { parent = Some((area, parent_shape_index, parent_contour_index)); } From 498a51ea6820b52135327a06a12055cc16aa9e71 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Thu, 17 Sep 2026 21:01:30 +0300 Subject: [PATCH 31/44] fix doc --- iOverlay/README.md | 16 ++++-- iOverlay/docs/stroke_math.md | 79 ++++++++++++++------------ iOverlay/src/mesh/float/style.rs | 5 +- iOverlay/src/mesh/int/stroke/offset.rs | 2 +- iOverlay/src/mesh/int/style.rs | 4 +- iOverlay/src/mesh/math.rs | 13 +++-- 6 files changed, 68 insertions(+), 51 deletions(-) diff --git a/iOverlay/README.md b/iOverlay/README.md index 4f7a1366..cca6955a 100644 --- a/iOverlay/README.md +++ b/iOverlay/README.md @@ -491,9 +491,9 @@ println!("result: {:?}", result); ## Buffering Outline, stroke, and variable-width stroke geometry is built by `mesh::int`. -The existing `mesh::float` APIs select a scale, convert input and styles, call the -integer API, and convert the result back. Fixed-scale methods retain the supplied -grid. Integer rounding and CORDIC arc subdivision can change individual vertices +The `mesh::float` APIs select a scale, convert input and styles, delegate +construction to the integer core, and convert the result back. Fixed-scale +methods retain the supplied grid. Integer rounding and CORDIC arc subdivision can change individual vertices compared with the former float builders. Use `IntOutlineOffset`, `IntStrokeOffset`, or `IntVariableStrokeOffset` from the @@ -513,14 +513,18 @@ Integer distances use input coordinate units without automatic rescaling. Stroke radius is `ceil(max(width, 0) / 2)`; radii at most 1 are degenerate. Use `validate_outline(&style)`, `validate_stroke(&style)`, or `validate_variable_stroke()` for an optional conservative coordinate-range check. -Construction asserts bounds in debug builds and trusts the caller in release. +Construction requires input and temporary coordinates to stay in the safe range. All three APIs provide `*_into` methods that replace a reusable flat output buffer. -Constant-width stroke also offers experimental float construction math through +Constant-width stroke also offers float construction math through `StrokeStyle::math(MathMode::Float)` or `IntStrokeStyle::math(MathMode::Float)`, with `MathMode` in `mesh::math`. Directions are stored as `UnitIntVector`; integer coordinates and boolean operations are retained. `Integer` remains the default. -See [stroke math modes and preliminary timings](docs/stroke_math.md) for details. + +Choose `MathMode::Integer` for cross-platform deterministic construction; +otherwise prefer `MathMode::Float` for higher precision and generally better speed. +Outline and variable-width stroke currently use Integer only. +See [stroke construction math](docs/stroke_math.md). ### Offsetting a Path Path Example diff --git a/iOverlay/docs/stroke_math.md b/iOverlay/docs/stroke_math.md index 86b953f7..f7bcc155 100644 --- a/iOverlay/docs/stroke_math.md +++ b/iOverlay/docs/stroke_math.md @@ -1,12 +1,32 @@ -# Stroke math prototype +# Stroke construction math -Both `StrokeStyle` and `IntStrokeStyle` accept `.math(MathMode::Float)` from `i_overlay::mesh::math`. `Integer` remains the default. This prototype selects math for constant-width stroke only; outline and variable-width stroke retain their current integer arithmetic. +Both `StrokeStyle` and `IntStrokeStyle` accept `.math(MathMode::Float)` from +`i_overlay::mesh::math`. This selects construction arithmetic for constant-width +stroke only; outline and variable-width stroke use integer construction math. + +Use `MathMode::Integer` when cross-platform deterministic construction is +required. For end-to-end reproducibility, use `mesh::int` with identical integer +inputs, styles, integer engine, and library version. Otherwise, prefer +`MathMode::Float`: its normalization and arc construction are more accurate and +generally faster than the approximate integer math. Total stroke performance +also depends on the geometry and the boolean operation. + +`Integer` remains the default; select `Float` explicitly. The `mesh::float` +namespace selects floating-point input coordinates, not construction arithmetic. +Both input APIs support either stroke math mode, and both modes retain integer +coordinates and boolean operations internally. Integer miter construction clamps the requested minimum interior angle to at least 5 degrees and clips sharper corners. Interior angles above 175 degrees use bevel joins. This uses the turn angle already computed for the join, without additional normalization or trigonometry. Float mode retains its existing angle policy, including ordinary miter intersections at nearly straight corners. +For `IntLineJoin::Miter`, the lower limit on the requested minimum is 5 degrees in +Integer mode or 0.01*pi (1.8 degrees) in Float mode; the upper limit is one +`Angle` unit below pi. The floating-point `LineJoin::Miter` style first clamps +its parameter to 0.01*pi..=0.99*pi (1.8..=178.2 degrees), before the selected +math mode applies its construction limits. Angular thresholds are quantized to +the `Angle` representation. ```rust use i_overlay::mesh::float::{stroke::offset::StrokeOffset, style::StrokeStyle}; @@ -17,47 +37,32 @@ let style = StrokeStyle::new(1.0).math(MathMode::Float); let result = path.stroke(style, false); ``` -Construction dispatches once to a generic `StrokeBuilder`. Float math computes normalization and trigonometry in f64, then stores directions in `UnitIntVector`. It uses the new checked `UnitIntVector::try_from_float` constructor from the local iFloat dependency. Scaling, custom-cap rotation, miter intersections, integer coordinates, and the boolean engine stay integer. Differences and cross/dot products are formed before float conversion to preserve local geometry at large i64 origins. +Construction dispatches once to a generic `StrokeBuilder`. Float math +computes normalization and trigonometry in f64, then stores directions in +`UnitIntVector`. Normalization uses `UnitIntVector::normalize_with_float`, and arc +samples use `UnitIntVector::from_float_unchecked`. Scaling, custom-cap rotation, +miter intersections, integer coordinates, and the boolean engine stay integer. +Point differences are formed before float conversion to preserve local geometry +at large i64 origins; angle calculations form direction cross/dot products before +converting them to f64. Float arcs cache a rotation and reuse their output allocation. Floating rotation state is retained between samples; quantization of an emitted direction is not fed back into the next rotation. The step reserves angular error for f64 arithmetic and fixed-scale component truncation. The original integer endpoints are retained. `ArcOptions::max_step` still applies, but `rotation_precision` is specific to the Integer mode and ignored by Float. -The checked constructor truncates components toward zero and verifies the integer squared norm. It does not normalize inputs. Float normalization/rotation is contracted slightly before conversion so rounding cannot create a direction longer than one. - -The modes may produce different rounded vertices and arc tessellations. Float mode does not promise cross-platform bitwise reproducibility. Defaults are unchanged; code using exhaustive style struct literals must now supply the new `math` field. - -## Preliminary timing - -Measured on macOS arm64 with rustc 1.98.1, optimized release build. Each operation uses 16 paths of 96 points. Smooth paths follow a sampled sine; jagged paths alternate height. Stroke width is 512 integer units, with a round start cap and square end cap. The reported value is the median of seven batches of ten calls, after one warmup call. Construction includes allocation; full stroke includes boolean extraction. +Conversion truncates fixed-scale components toward zero without checking the +integer squared norm. Float normalization and rotation are approximate: directions +may be slightly longer than one. There is no contraction step or guarantee of an +exact norm bound. Float stroke bounds reserve a margin for numerical drift and +coordinate rounding. Final points still lie on the integer grid, so Float mode +does not eliminate coordinate quantization or rounding-sensitive intersections. -These are synthetic cases on one machine, not a guarantee of application speedup. Output tessellations differ slightly: Float emitted 16 fewer segments in each case (one per path), so end-to-end numbers do not isolate arithmetic cost. - -| Engine | Paths | Join | Build Integer / Float, µs | Full Integer / Float, µs | Full time reduction | -|---|---|---|---:|---:|---:| -| i32 | smooth | Bevel | 129.96 / 33.80 | 2610.26 / 2588.36 | 0.8% | -| i32 | smooth | Miter | 85.73 / 53.88 | 3522.31 / 3447.71 | 2.1% | -| i32 | smooth | Round | 69.13 / 48.25 | 2640.53 / 2637.67 | 0.1% | -| i32 | jagged | Bevel | 44.28 / 34.40 | 1150.73 / 1143.12 | 0.7% | -| i32 | jagged | Miter | 327.85 / 310.98 | 1794.52 / 1754.57 | 2.2% | -| i32 | jagged | Round | 116.77 / 81.83 | 1706.99 / 1664.77 | 2.5% | -| i64 | smooth | Bevel | 135.62 / 49.92 | 3122.18 / 3014.88 | 3.4% | -| i64 | smooth | Miter | 193.69 / 100.92 | 4106.54 / 3946.51 | 3.9% | -| i64 | smooth | Round | 227.73 / 81.46 | 3189.83 / 3040.92 | 4.7% | -| i64 | jagged | Bevel | 138.93 / 47.17 | 1388.85 / 1293.50 | 6.9% | -| i64 | jagged | Miter | 929.12 / 812.11 | 2581.74 / 2478.71 | 4.0% | -| i64 | jagged | Round | 285.85 / 136.63 | 2112.45 / 1932.25 | 8.5% | - -Normalization alone was about 1.8–4.8× faster in these cases. Segment construction improved more than total stroke time; boolean work dominates the full operation. Keep Float opt-in while collecting representative application benchmarks. - -Reproduce: - -```sh -cargo test --release --lib benchmark_math_modes -- --ignored --nocapture -``` +The modes may produce different rounded vertices and arc tessellations. Float +mode does not promise cross-platform bitwise reproducibility. Code using +exhaustive style struct literals must supply the `math` field. ## Validation -- Existing integer mesh/arc/outline regression tests retain the default behavior. +- Integer mesh/arc/outline regression tests exercise the default construction mode. +- Miter regressions cover almost straight strokes and shrinking outlines, the 175-degree bevel cutoff, and the 5-degree clipping floor, with separate Float controls. - Both math modes cover constant-width round-stroke vertex disks, path reversal, coarse caps, empty paths, duplicates, and reused flat output. -- Float arc tests check ordered samples, maximum angular gaps, exact integer norm bounds, and allocation reuse for i16/i32/i64. +- Float arc tests check ordered samples, maximum angular gaps, approximate unit length within a numerical tolerance, and allocation reuse for i16/i32/i64. - Large-origin i64 tests translate the input by 2^60 and compare the translated-back stroke. -- iFloat constructor tests include non-finite/out-of-range input and a norm violation too small to detect by squaring in f64. diff --git a/iOverlay/src/mesh/float/style.rs b/iOverlay/src/mesh/float/style.rs index 94da4ec8..477d3244 100644 --- a/iOverlay/src/mesh/float/style.rs +++ b/iOverlay/src/mesh/float/style.rs @@ -31,10 +31,11 @@ pub enum LineJoin { /// Cuts off the corner where two lines meet. This is the default. Bevel, /// Creates a sharp corner where two lines meet. - /// The corner is limited by a miter, where the parameter `Angle` - /// is a minimum sharp angle + /// The parameter is the minimum interior angle in radians, clamped to + /// 0.01*pi..=0.99*pi (1.8..=178.2 degrees) before integer conversion. /// With Integer construction math, the effective minimum is at least /// 5 degrees, and interior angles above 175 degrees use bevel joins. + /// Float construction math uses the converted angle without that cutoff. Miter(T), /// Creates an arc corner where two lines meet. /// The arc is approximated using a group of segments, where the parameter `Angle` diff --git a/iOverlay/src/mesh/int/stroke/offset.rs b/iOverlay/src/mesh/int/stroke/offset.rs index a296ce15..8632b696 100644 --- a/iOverlay/src/mesh/int/stroke/offset.rs +++ b/iOverlay/src/mesh/int/stroke/offset.rs @@ -13,7 +13,7 @@ pub type IntStrokeError = IntOutlineError; /// Strokes integer paths with radius `ceil(max(width, 0) / 2)`. /// Radii at most one produce no geometry. /// Coordinates, caps and temporary joins must stay in the engine's safe range. -/// Validation is optional in release and asserted in debug builds. +/// Use [`Self::validate_stroke`] for an optional conservative bounds check. /// /// ``` /// use i_float::int::point::IntPoint; diff --git a/iOverlay/src/mesh/int/style.rs b/iOverlay/src/mesh/int/style.rs index 389e4d7c..70384353 100644 --- a/iOverlay/src/mesh/int/style.rs +++ b/iOverlay/src/mesh/int/style.rs @@ -12,7 +12,9 @@ pub enum IntLineJoin { /// Integer math clamps the minimum interior angle to at least 5 degrees /// and uses bevel joins for interior angles above 175 degrees. /// Float math clamps the minimum to 0.01*pi (1.8 degrees), without the - /// near-straight bevel cutoff. Both clamp the maximum below pi. + /// near-straight bevel cutoff. Both clamp the maximum to one Angle unit + /// below pi. The floating-point LineJoin adapter additionally clamps its + /// input angle to 0.01*pi..=0.99*pi before conversion to this type. Miter(Angle), /// Rounded join with reusable integer rotation settings. Round(ArcOptions), diff --git a/iOverlay/src/mesh/math.rs b/iOverlay/src/mesh/math.rs index 6d04c931..775a5b93 100644 --- a/iOverlay/src/mesh/math.rs +++ b/iOverlay/src/mesh/math.rs @@ -2,13 +2,18 @@ /// /// Both modes retain integer coordinates and use the same integer boolean engine. /// They can produce different rounded vertices and arc tessellations. +/// Use Integer for cross-platform deterministic construction with identical +/// integer inputs, settings, engine, and library version. Otherwise, prefer Float +/// for more accurate normalization and arcs and generally better performance. +/// The input namespace (`mesh::int` or `mesh::float`) does not select this mode. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum MathMode { - /// Existing fixed-point directions and integer trigonometry. + /// Fixed-point directions and integer trigonometry. This is the default. #[default] Integer, - /// Experimental f64 normalization and trigonometry. Directions are stored - /// as UnitIntVector, then scaled with integer arithmetic; cross-platform bitwise - /// reproducibility is not promised. Arc rotation_precision is ignored. + /// f64 normalization and trigonometry. Directions are stored as UnitIntVector + /// without a norm check, then scaled with integer arithmetic. Their length + /// may slightly exceed one. Cross-platform bitwise reproducibility is not + /// promised. Arc rotation_precision is ignored. Float, } From 60187539ea332db18583209424b0c9cd7af0f803 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Thu, 17 Sep 2026 21:40:45 +0300 Subject: [PATCH 32/44] math solver to variable stroke and outline --- iOverlay/docs/stroke_math.md | 32 +- iOverlay/src/mesh/float/outline/offset.rs | 2 + iOverlay/src/mesh/float/style.rs | 9 + .../src/mesh/float/variable_stroke/offset.rs | 72 +- .../src/mesh/float/variable_stroke/style.rs | 10 + iOverlay/src/mesh/int/outline/bounds.rs | 28 +- iOverlay/src/mesh/int/outline/build.rs | 103 +-- iOverlay/src/mesh/int/outline/builder.rs | 13 +- iOverlay/src/mesh/int/outline/builder_join.rs | 4 +- iOverlay/src/mesh/int/outline/offset.rs | 5 +- iOverlay/src/mesh/int/outline/section.rs | 8 +- iOverlay/src/mesh/int/style.rs | 9 + .../src/mesh/int/variable_stroke/build.rs | 162 +++++ .../src/mesh/int/variable_stroke/builder.rs | 619 +++--------------- .../mesh/int/variable_stroke/builder_join.rs | 356 ++++++++++ iOverlay/src/mesh/int/variable_stroke/math.rs | 108 +++ iOverlay/src/mesh/int/variable_stroke/mod.rs | 3 + .../src/mesh/int/variable_stroke/offset.rs | 57 +- .../src/mesh/int/variable_stroke/section.rs | 67 +- .../src/mesh/int/variable_stroke/style.rs | 8 + .../src/mesh/int/variable_stroke/tests.rs | 68 +- .../int/variable_stroke/tests_reference.rs | 164 +++++ iOverlay/src/mesh/math.rs | 5 +- iOverlay/tests/outline_variable_math_tests.rs | 270 ++++++++ iOverlay/tests/variable_stroke_stress.rs | 22 +- 25 files changed, 1480 insertions(+), 724 deletions(-) create mode 100644 iOverlay/src/mesh/int/variable_stroke/build.rs create mode 100644 iOverlay/src/mesh/int/variable_stroke/builder_join.rs create mode 100644 iOverlay/src/mesh/int/variable_stroke/math.rs create mode 100644 iOverlay/src/mesh/int/variable_stroke/tests_reference.rs create mode 100644 iOverlay/tests/outline_variable_math_tests.rs diff --git a/iOverlay/docs/stroke_math.md b/iOverlay/docs/stroke_math.md index f7bcc155..84bc0487 100644 --- a/iOverlay/docs/stroke_math.md +++ b/iOverlay/docs/stroke_math.md @@ -1,8 +1,8 @@ -# Stroke construction math +# Mesh construction math -Both `StrokeStyle` and `IntStrokeStyle` accept `.math(MathMode::Float)` from -`i_overlay::mesh::math`. This selects construction arithmetic for constant-width -stroke only; outline and variable-width stroke use integer construction math. +The integer and floating-point styles for stroke, outline, and variable-width +stroke all accept `.math(MathMode::Float)` from `i_overlay::mesh::math`. This +selects construction arithmetic, independently of the input coordinate type. Use `MathMode::Integer` when cross-platform deterministic construction is required. For end-to-end reproducibility, use `mesh::int` with identical integer @@ -13,7 +13,7 @@ also depends on the geometry and the boolean operation. `Integer` remains the default; select `Float` explicitly. The `mesh::float` namespace selects floating-point input coordinates, not construction arithmetic. -Both input APIs support either stroke math mode, and both modes retain integer +Both input APIs support either math mode, and both modes retain integer coordinates and boolean operations internally. Integer miter construction clamps the requested minimum interior angle to at @@ -37,7 +37,7 @@ let style = StrokeStyle::new(1.0).math(MathMode::Float); let result = path.stroke(style, false); ``` -Construction dispatches once to a generic `StrokeBuilder`. Float math +Construction dispatches once to a generic builder using `IntegerMath` or `FloatMath`. Float math computes normalization and trigonometry in f64, then stores directions in `UnitIntVector`. Normalization uses `UnitIntVector::normalize_with_float`, and arc samples use `UnitIntVector::from_float_unchecked`. Scaling, custom-cap rotation, @@ -51,7 +51,7 @@ Float arcs cache a rotation and reuse their output allocation. Floating rotation Conversion truncates fixed-scale components toward zero without checking the integer squared norm. Float normalization and rotation are approximate: directions may be slightly longer than one. There is no contraction step or guarantee of an -exact norm bound. Float stroke bounds reserve a margin for numerical drift and +exact norm bound. Float stroke and outline bounds reserve a margin for numerical drift and coordinate rounding. Final points still lie on the integer grid, so Float mode does not eliminate coordinate quantization or rounding-sensitive intersections. @@ -59,6 +59,20 @@ The modes may produce different rounded vertices and arc tessellations. Float mode does not promise cross-platform bitwise reproducibility. Code using exhaustive style struct literals must supply the `math` field. +Variable-width strokes also select the tangent-contact calculation through a +private `VariableStrokeMath` extension of `MeshMath`. Integer mode retains the +scaled integer square root and rounded multiply/divide calculation. Float mode +uses the same external-tangent formula in f64 and rounds local offsets before +adding integer centers. The squared-length difference is computed in integers +before conversion, preserving small positive differences near containment. +Containment and orientation predicates remain integer operations. + +Variable-width paths are consumed once with a current chain and previous section; +the float adapter converts vertices lazily. There are no intermediate path or +subsegment collections. Arc storage is reused across paths. Debug edge collection +uses the same traversal and records the same emitted geometry. The style-free +`validate_variable_stroke()` uses conservative padding valid for both math modes. + ## Validation - Integer mesh/arc/outline regression tests exercise the default construction mode. @@ -66,3 +80,7 @@ exhaustive style struct literals must supply the `math` field. - Both math modes cover constant-width round-stroke vertex disks, path reversal, coarse caps, empty paths, duplicates, and reused flat output. - Float arc tests check ordered samples, maximum angular gaps, approximate unit length within a numerical tolerance, and allocation reuse for i16/i32/i64. - Large-origin i64 tests translate the input by 2^60 and compare the translated-back stroke. + +- Both modes cover outline expansion/contraction and variable-width stroke areas for i16/i32/i64, flat output, and large-origin translation. +- A deterministic randomized test compares streaming integer variable-stroke segments with the former partitioned traversal. +- Variable-width stress tests exercise both construction modes, including reversed paths and the i64 engine. diff --git a/iOverlay/src/mesh/float/outline/offset.rs b/iOverlay/src/mesh/float/outline/offset.rs index 3d06c1e7..64526259 100644 --- a/iOverlay/src/mesh/float/outline/offset.rs +++ b/iOverlay/src/mesh/float/outline/offset.rs @@ -409,6 +409,7 @@ where outer_offset: style.outer_offset, inner_offset: style.inner_offset, join: style.join.clone(), + math: style.math, }, adapter, }) @@ -425,6 +426,7 @@ where outer_offset: self.adapter.round_len_to_int(self.style.outer_offset), inner_offset: self.adapter.round_len_to_int(self.style.inner_offset), join: (&self.style.join).into(), + math: self.style.math, } } diff --git a/iOverlay/src/mesh/float/style.rs b/iOverlay/src/mesh/float/style.rs index 477d3244..3845c3b2 100644 --- a/iOverlay/src/mesh/float/style.rs +++ b/iOverlay/src/mesh/float/style.rs @@ -64,6 +64,8 @@ pub struct OutlineStyle { pub outer_offset: T, pub inner_offset: T, pub join: LineJoin, + /// Arithmetic used to construct offsets and joins. + pub math: MathMode, } impl LineCap

{ @@ -239,6 +241,12 @@ impl OutlineStyle { } } + /// Selects construction arithmetic; boolean operations stay integer. + pub fn math(mut self, math: MathMode) -> Self { + self.math = math; + self + } + /// Sets the offset distance. pub fn offset(mut self, offset: T) -> Self { self.outer_offset = offset; @@ -271,6 +279,7 @@ impl Default for OutlineStyle { outer_offset: T::from_float(1.0), inner_offset: T::from_float(1.0), join: LineJoin::Bevel, + math: MathMode::Integer, } } } diff --git a/iOverlay/src/mesh/float/variable_stroke/offset.rs b/iOverlay/src/mesh/float/variable_stroke/offset.rs index 62510e7b..b0c1adff 100644 --- a/iOverlay/src/mesh/float/variable_stroke/offset.rs +++ b/iOverlay/src/mesh/float/variable_stroke/offset.rs @@ -1,12 +1,12 @@ use crate::core::integer::OverlayInt; +use crate::core::{fill_rule::FillRule, overlay_rule::OverlayRule}; use crate::float::overlay::OverlayOptions; use crate::float::scale::FixedScaleOverlayError; use crate::mesh::float::variable_stroke::resource::VariableStrokeSource; use crate::mesh::float::variable_stroke::style::VariableStrokeStyle; -use crate::mesh::int::variable_stroke::offset::IntVariableStrokeOffset; +use crate::mesh::int::variable_stroke::build::build_variable_overlay_iter; use crate::mesh::int::variable_stroke::{IntStrokeVertex, IntVariableStrokeStyle}; use alloc::vec; -use alloc::vec::Vec; use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; use i_float::float::number::FloatNumber; @@ -295,27 +295,23 @@ where fn int_style(&self) -> IntVariableStrokeStyle { IntVariableStrokeStyle { + math: self.style.math, arc: crate::mesh::int::arc::ArcOptions { max_step: Angle::from_radians(self.style.round_angle), ..Default::default() }, } } - fn int_paths + ?Sized>(&self, source: &S) -> Vec>> { - source - .iter_variable_paths() - .map(|path| { - path.iter() - .map(|v| { - let radius = self.adapter.round_len_to_int(v.radius()).to_wide(); - IntStrokeVertex::new( - self.adapter.float_to_int(&v.point), - I::from_wide(radius + radius), - ) - }) - .collect() + fn int_paths<'a, S: VariableStrokeSource

+ ?Sized>( + &'a self, + source: &'a S, + ) -> impl Iterator> + 'a> + 'a { + source.iter_variable_paths().map(|path| { + path.iter().map(|v| { + let radius = self.adapter.round_len_to_int(v.radius()).to_wide(); + IntStrokeVertex::new(self.adapter.float_to_int(&v.point), I::from_wide(radius + radius)) }) - .collect() + }) } fn build + ?Sized>( @@ -327,10 +323,14 @@ where return vec![]; } - let paths = self.int_paths(source); - let shapes = paths - .variable_stroke_custom(self.int_style(), options.int_with_adapter(&self.adapter)) - .expect("valid integer variable stroke"); + let shapes = build_variable_overlay_iter( + self.int_paths(source), + self.int_style(), + options.int_with_adapter(&self.adapter), + #[cfg(feature = "variable_stroke_debug")] + None, + ) + .overlay(OverlayRule::Subject, FillRule::Positive); let mut float = shapes.to_float(&self.adapter); if options.clean_result { @@ -354,15 +354,15 @@ where return; } - let paths = self.int_paths(source); let mut int_output = FlatContoursBuffer::::with_capacity(0); - paths - .variable_stroke_custom_into( - self.int_style(), - options.int_with_adapter(&self.adapter), - &mut int_output, - ) - .expect("valid integer variable stroke"); + build_variable_overlay_iter( + self.int_paths(source), + self.int_style(), + options.int_with_adapter(&self.adapter), + #[cfg(feature = "variable_stroke_debug")] + None, + ) + .overlay_into(OverlayRule::Subject, FillRule::Positive, &mut int_output); let iter = int_output .points @@ -391,12 +391,15 @@ where }; } - let paths = self.int_paths(source); - let result = paths - .variable_stroke_debug(self.int_style(), options.int_with_adapter(&self.adapter)) - .expect("valid integer variable stroke"); - let edges = result - .edges + let mut edges = alloc::vec::Vec::new(); + let shapes = build_variable_overlay_iter( + self.int_paths(source), + self.int_style(), + options.int_with_adapter(&self.adapter), + Some(&mut edges), + ) + .overlay(OverlayRule::Subject, FillRule::Positive); + let edges = edges .into_iter() .map( |edge| crate::mesh::float::variable_stroke::VariableStrokeDebugEdge { @@ -408,7 +411,6 @@ where }, ) .collect(); - let shapes = result.shapes; let mut shapes = shapes.to_float(&self.adapter); if options.clean_result { diff --git a/iOverlay/src/mesh/float/variable_stroke/style.rs b/iOverlay/src/mesh/float/variable_stroke/style.rs index 83188ff5..df57443b 100644 --- a/iOverlay/src/mesh/float/variable_stroke/style.rs +++ b/iOverlay/src/mesh/float/variable_stroke/style.rs @@ -1,3 +1,4 @@ +use crate::mesh::math::MathMode; use core::f64::consts::PI; use i_float::float::compatible::FloatPointCompatible; use i_float::float::number::FloatNumber; @@ -26,6 +27,8 @@ impl StrokeVertex

{ pub struct VariableStrokeStyle { /// Maximum angular step used to approximate round joins and caps, in radians. pub round_angle: T, + /// Arithmetic used to construct tangent contacts and arcs. + pub math: MathMode, } impl VariableStrokeStyle { @@ -34,6 +37,11 @@ impl VariableStrokeStyle { Self::default() } + pub fn math(mut self, math: MathMode) -> Self { + self.math = math; + self + } + #[inline] pub fn round_angle(mut self, angle: T) -> Self { self.round_angle = Self::normalize_angle(angle); @@ -44,6 +52,7 @@ impl VariableStrokeStyle { pub(super) fn normalized(self) -> Self { Self { round_angle: Self::normalize_angle(self.round_angle), + math: self.math, } } @@ -58,6 +67,7 @@ impl Default for VariableStrokeStyle { fn default() -> Self { Self { round_angle: T::from_float(0.1), + math: MathMode::Integer, } } } diff --git a/iOverlay/src/mesh/int/outline/bounds.rs b/iOverlay/src/mesh/int/outline/bounds.rs index 177388b2..449235c8 100644 --- a/iOverlay/src/mesh/int/outline/bounds.rs +++ b/iOverlay/src/mesh/int/outline/bounds.rs @@ -1,30 +1,23 @@ use super::offset::IntOutlineError; use crate::mesh::int::join::Join; +use crate::mesh::int::math::{backend::MeshMath, float::FloatMath, integer::IntegerMath}; use crate::mesh::int::style::IntOutlineStyle; +use crate::mesh::math::MathMode; use i_float::int::number::int::IntNumber; use i_float::int::rect::IntRect; use i_shape::source::int::resource::IntShapeResource; pub(super) trait OutlineBounds: IntShapeResource { fn validate_outline_bounds(&self, style: &IntOutlineStyle) -> Result<(), IntOutlineError> { - let padding = Join::::padding(style.join, style.outer_offset) - .max(Join::::padding(style.join, style.inner_offset)); + let padding = match style.math { + MathMode::Integer => outline_padding::(style), + MathMode::Float => outline_padding::(style), + }; let Some(rect) = IntRect::with_iter(self.iter_paths().flatten()) else { return Ok(()); }; - // Expand in the wide type, including when an offset is I::MIN. Saturating - // at storage limits prevents wrapping during narrowing: these limits are - // themselves outside is_in_safe_range, so an oversized bound stays invalid. - let min = I::MIN.to_wide(); - let max = I::MAX.to_wide(); - let expanded = IntRect::new( - I::from_wide((rect.min_x.to_wide() - padding).max(min)), - I::from_wide((rect.max_x.to_wide() + padding).min(max)), - I::from_wide((rect.min_y.to_wide() - padding).max(min)), - I::from_wide((rect.max_y.to_wide() + padding).min(max)), - ); - if expanded.is_in_safe_range() { + if crate::mesh::int::bounds::expanded_is_safe(rect, padding) { Ok(()) } else { Err(IntOutlineError::CoordinateOutOfRange) @@ -33,3 +26,10 @@ pub(super) trait OutlineBounds: IntShapeResource { } impl + ?Sized> OutlineBounds for S {} + +fn outline_padding>(style: &IntOutlineStyle) -> I::Wide { + M::guard_padding( + Join::::padding(style.join, style.outer_offset) + .max(Join::::padding(style.join, style.inner_offset)), + ) +} diff --git a/iOverlay/src/mesh/int/outline/build.rs b/iOverlay/src/mesh/int/outline/build.rs index bc3e641a..eddafb33 100644 --- a/iOverlay/src/mesh/int/outline/build.rs +++ b/iOverlay/src/mesh/int/outline/build.rs @@ -5,7 +5,9 @@ use crate::core::integer::OverlayInt; use crate::core::overlay::{ContourDirection, IntOverlayOptions, Overlay, ShapeType}; use crate::core::overlay_rule::OverlayRule; use crate::mesh::int::join::Join; +use crate::mesh::int::math::{backend::MeshMath, float::FloatMath, integer::IntegerMath}; use crate::mesh::int::style::IntOutlineStyle; +use crate::mesh::math::MathMode; use alloc::vec::Vec; use i_float::int::number::uint::UIntNumber; use i_float::int::number::wide_int::WideIntNumber; @@ -28,54 +30,73 @@ where style: &IntOutlineStyle, options: IntOverlayOptions, ) -> Overlay { - let mut outer_builder = OutlineBuilder::new(style.outer_offset, Join::::new(style.join)); - let mut inner_builder = OutlineBuilder::new(style.inner_offset, Join::::new(style.join)); + match style.math { + MathMode::Integer => { + build_outline_overlay_with_math::(self, style, options) + } + MathMode::Float => build_outline_overlay_with_math::(self, style, options), + } + } +} + +fn build_outline_overlay_with_math( + paths: Paths, + style: &IntOutlineStyle, + options: IntOverlayOptions, +) -> Overlay +where + I: OverlayInt, + M: MeshMath, + Paths: IntoIterator, + Path: ExactSizeIterator> + Clone, +{ + let mut outer_builder = OutlineBuilder::new(style.outer_offset, Join::::new(style.join)); + let mut inner_builder = OutlineBuilder::new(style.inner_offset, Join::::new(style.join)); - let mut overlay = Overlay::new_custom(0, options, Default::default()); - let mut contour_options = options; + let mut overlay = Overlay::new_custom(0, options, Default::default()); + let mut contour_options = options; - // Contours below the threshold can join into a larger surviving result. - contour_options.min_output_area = I::WideUInt::ZERO; + // Contours below the threshold can join into a larger surviving result. + contour_options.min_output_area = I::WideUInt::ZERO; - let mut contour_overlay = Overlay::new_custom(0, contour_options, Default::default()); - let mut segments = Vec::new(); - let mut extraction = BooleanExtractionBuffer::default(); - let mut contours = FlatContoursBuffer::default(); + let mut contour_overlay = Overlay::new_custom(0, contour_options, Default::default()); + let mut segments = Vec::new(); + let mut extraction = BooleanExtractionBuffer::default(); + let mut contours = FlatContoursBuffer::default(); - for path in self { - if path.len() < 3 { - continue; - } - let area = path.clone().area_two(); - if area == I::Wide::ZERO { - continue; - } - let (builder, direction, fill) = if area > I::Wide::ZERO { - ( - &mut outer_builder, - ContourDirection::CounterClockwise, - FillRule::Positive, - ) - } else { - ( - &mut inner_builder, - ContourDirection::Clockwise, - FillRule::Negative, - ) - }; - segments.clear(); - builder.build(path, &mut segments); + for path in paths { + if path.len() < 3 { + continue; + } + let area = path.clone().area_two(); + if area == I::Wide::ZERO { + continue; + } + let (builder, direction, fill) = if area > I::Wide::ZERO { + ( + &mut outer_builder, + ContourDirection::CounterClockwise, + FillRule::Positive, + ) + } else { + ( + &mut inner_builder, + ContourDirection::Clockwise, + FillRule::Negative, + ) + }; + segments.clear(); + builder.build(path, &mut segments); - contour_overlay.options.output_direction = direction; - contour_overlay.clear(); - contour_overlay.add_segments(&segments); - if let Some(graph) = contour_overlay.build_graph_view(fill) { - graph.extract_contours_into(OverlayRule::Subject, &mut extraction, &mut contours); - overlay.add_flat_buffer(&contours, ShapeType::Subject); - } + contour_overlay.options.output_direction = direction; + contour_overlay.clear(); + contour_overlay.add_segments(&segments); + if let Some(graph) = contour_overlay.build_graph_view(fill) { + graph.extract_contours_into(OverlayRule::Subject, &mut extraction, &mut contours); + overlay.add_flat_buffer(&contours, ShapeType::Subject); } - overlay } + overlay } #[cfg(test)] diff --git a/iOverlay/src/mesh/int/outline/builder.rs b/iOverlay/src/mesh/int/outline/builder.rs index 08a4a65d..2f04faf2 100644 --- a/iOverlay/src/mesh/int/outline/builder.rs +++ b/iOverlay/src/mesh/int/outline/builder.rs @@ -1,5 +1,6 @@ use super::builder_join::JoinBuilder; use super::section::OffsetSection; +use crate::mesh::int::{join::Join, math::backend::MeshMath}; use crate::mesh::subject::SubjectSegments; use crate::mesh::uniq_iter::UniqueSegmentsIter; use crate::segm::boolean::ShapeCountBoolean; @@ -9,13 +10,13 @@ use i_float::int::number::int::IntNumber; use i_float::int::number::wide_int::WideIntNumber; use i_float::int::point::IntPoint; -pub(super) struct OutlineBuilder> { +pub(super) struct OutlineBuilder> { offset: I, - join_builder: J, + join_builder: Join, } -impl> OutlineBuilder { - pub(super) fn new(offset: I, join_builder: J) -> Self { +impl> OutlineBuilder { + pub(super) fn new(offset: I, join_builder: Join) -> Self { Self { offset, join_builder } } @@ -29,11 +30,11 @@ impl> OutlineBuilder { let Some(first) = iter.next() else { return; }; - let first = OffsetSection::new(first, self.offset); + let first = OffsetSection::new::(first, self.offset); segments.push_non_degenerate(first.a_top, first.b_top); let mut previous = first; for segment in iter { - let next = OffsetSection::new(segment, self.offset); + let next = OffsetSection::new::(segment, self.offset); segments.push_non_degenerate(next.a_top, next.b_top); self.feed_join(&previous, &next, segments); previous = next; diff --git a/iOverlay/src/mesh/int/outline/builder_join.rs b/iOverlay/src/mesh/int/outline/builder_join.rs index 19af290b..77e70225 100644 --- a/iOverlay/src/mesh/int/outline/builder_join.rs +++ b/iOverlay/src/mesh/int/outline/builder_join.rs @@ -14,7 +14,9 @@ pub(super) trait JoinBuilder { ); } -impl JoinBuilder for crate::mesh::int::join::Join { +impl> JoinBuilder + for crate::mesh::int::join::Join +{ fn add_join( &mut self, previous: &OffsetSection, diff --git a/iOverlay/src/mesh/int/outline/offset.rs b/iOverlay/src/mesh/int/outline/offset.rs index 0844dffc..cd4c6381 100644 --- a/iOverlay/src/mesh/int/outline/offset.rs +++ b/iOverlay/src/mesh/int/outline/offset.rs @@ -39,9 +39,8 @@ impl core::error::Error for IntOutlineError {} /// Contour signed double areas must also fit in `I::Wide` /// (including repeated winding). /// -/// Uses [`fast_normalize`](i_float::int::vector::IntVector::fast_normalize) -/// with about 6/14/30 bits of direction precision for i16/i32/i64. -/// Scaling a direction also scales its approximation error. +/// Construction arithmetic is selected by [`IntOutlineStyle::math`]. +/// Integer math is deterministic; float math may produce different rounded vertices. /// /// ``` /// use i_overlay::i_float::int::point::IntPoint; diff --git a/iOverlay/src/mesh/int/outline/section.rs b/iOverlay/src/mesh/int/outline/section.rs index ba4b7648..62d30484 100644 --- a/iOverlay/src/mesh/int/outline/section.rs +++ b/iOverlay/src/mesh/int/outline/section.rs @@ -1,4 +1,4 @@ -use crate::mesh::int::math::{direction, point}; +use crate::mesh::int::math::{backend::MeshMath, point}; use crate::mesh::uniq_iter::UniqueSegment; use i_float::int::number::int::IntNumber; use i_float::int::point::IntPoint; @@ -15,9 +15,9 @@ pub(super) struct OffsetSection { } impl OffsetSection { - pub(super) fn new(segment: UniqueSegment, offset: I) -> Self { + pub(super) fn new>(segment: UniqueSegment, offset: I) -> Self { let (a, b) = (segment.a, segment.b); - let direction = direction(b - a).expect("unique segment"); + let direction = M::normalize(b - a).expect("unique segment"); if offset == I::ZERO { return Self { offset, @@ -28,7 +28,7 @@ impl OffsetSection { b_top: b, }; } - let scaled = direction.scale(offset); + let scaled = M::scale(direction, offset); let dx = scaled.y; let dy = -scaled.x; diff --git a/iOverlay/src/mesh/int/style.rs b/iOverlay/src/mesh/int/style.rs index 70384353..40529d06 100644 --- a/iOverlay/src/mesh/int/style.rs +++ b/iOverlay/src/mesh/int/style.rs @@ -28,6 +28,8 @@ pub struct IntOutlineStyle { pub outer_offset: I, pub inner_offset: I, pub join: IntLineJoin, + /// Arithmetic used to construct offsets and joins. + pub math: MathMode, } impl IntOutlineStyle { @@ -36,9 +38,16 @@ impl IntOutlineStyle { outer_offset: offset, inner_offset: offset, join: IntLineJoin::Bevel, + math: MathMode::Integer, } } + /// Selects construction arithmetic; boolean operations stay integer. + pub fn math(mut self, math: MathMode) -> Self { + self.math = math; + self + } + pub fn offset(mut self, offset: I) -> Self { self.outer_offset = offset; self.inner_offset = offset; diff --git a/iOverlay/src/mesh/int/variable_stroke/build.rs b/iOverlay/src/mesh/int/variable_stroke/build.rs new file mode 100644 index 00000000..7ed403e0 --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/build.rs @@ -0,0 +1,162 @@ +#[cfg(feature = "variable_stroke_debug")] +use super::debug::IntVariableStrokeDebugEdge; +use super::offset::IntVariableStrokeOffset; +use super::{ + IntStrokeVertex, IntVariableStrokeStyle, builder::VariableStrokeBuilder, math::VariableStrokeMath, +}; +use crate::core::{ + integer::OverlayInt, + overlay::{IntOverlayOptions, Overlay}, +}; +use crate::mesh::{ + int::math::{float::FloatMath, integer::IntegerMath}, + math::MathMode, +}; +use alloc::vec::Vec; +pub(super) trait BuildVariableOverlay: IntVariableStrokeOffset { + fn build_variable_overlay( + &self, + style: IntVariableStrokeStyle, + options: IntOverlayOptions, + ) -> Overlay { + build_variable_overlay_iter( + self.iter_variable_paths().map(|path| path.iter().copied()), + style, + options, + #[cfg(feature = "variable_stroke_debug")] + None, + ) + } +} +impl + ?Sized> BuildVariableOverlay for S {} + +/// Consumes each path once, including float inputs mapped to integer vertices. +pub(crate) fn build_variable_overlay_iter( + paths: Paths, + style: IntVariableStrokeStyle, + options: IntOverlayOptions, + #[cfg(feature = "variable_stroke_debug")] edges: Option<&mut Vec>>, +) -> Overlay +where + I: OverlayInt, + Paths: IntoIterator, + Path: IntoIterator>, +{ + match style.math { + MathMode::Integer => build_with_math::( + paths, + style, + options, + #[cfg(feature = "variable_stroke_debug")] + edges, + ), + MathMode::Float => build_with_math::( + paths, + style, + options, + #[cfg(feature = "variable_stroke_debug")] + edges, + ), + } +} + +fn build_with_math( + paths: Paths, + style: IntVariableStrokeStyle, + options: IntOverlayOptions, + #[cfg(feature = "variable_stroke_debug")] mut edges: Option<&mut Vec>>, +) -> Overlay +where + I: OverlayInt, + M: VariableStrokeMath, + Paths: IntoIterator, + Path: IntoIterator>, +{ + let mut builder = VariableStrokeBuilder::::new(style); + let mut segments = Vec::new(); + for (_index, path) in paths.into_iter().enumerate() { + #[cfg(debug_assertions)] + let path = path.into_iter().inspect(|vertex| { + debug_assert!( + crate::mesh::int::bounds::expanded_is_safe( + i_float::int::rect::IntRect::with_point(vertex.point), + M::guard_padding(vertex.radius().to_wide()), + ), + "variable stroke exceeds coordinate range" + ); + }); + builder.build( + path, + &mut segments, + #[cfg(feature = "variable_stroke_debug")] + edges.as_deref_mut(), + #[cfg(feature = "variable_stroke_debug")] + _index, + ); + } + let mut overlay = Overlay::with_segments(segments); + overlay.options = options; + overlay +} + +#[cfg(test)] +mod tests { + use super::super::offset::IntVariableStrokeOffset; + use super::*; + use crate::core::{fill_rule::FillRule, overlay_rule::OverlayRule}; + use alloc::vec; + use core::cell::Cell; + use i_float::int::point::IntPoint; + + #[test] + fn paths_and_vertices_are_consumed_once_in_order() { + let vertex = |x, y, width| IntStrokeVertex::new(IntPoint::new(x, y), width); + let paths = vec![ + vec![], + vec![vertex(0, 0, 1000)], + vec![vertex(0, 0, 200), vertex(0, 0, 400), vertex(4000, 3000, 2000)], + vec![], + vec![vertex(0, 0, 0)], + ]; + for math in [MathMode::Integer, MathMode::Float] { + let style = IntVariableStrokeStyle::new().math(math); + let expected = paths.variable_stroke(style).unwrap(); + let visited = Cell::new(0); + let visited_ref = &visited; + let mut expected_visited = 0; + let iter = paths.iter().map(|path| { + assert_eq!(visited.get(), expected_visited); + expected_visited += path.len(); + let mut vertices = path.iter().copied(); + core::iter::from_fn(move || { + let vertex = vertices.next()?; + visited_ref.set(visited_ref.get() + 1); + Some(vertex) + }) + }); + let actual = build_variable_overlay_iter( + iter, + style, + Default::default(), + #[cfg(feature = "variable_stroke_debug")] + None, + ) + .overlay(OverlayRule::Subject, FillRule::Positive); + assert_eq!(actual, expected); + assert_eq!(visited.get(), paths.iter().map(Vec::len).sum::()); + } + } + + #[cfg(debug_assertions)] + #[test] + #[should_panic(expected = "variable stroke exceeds coordinate range")] + fn invalid_zero_width_vertex_is_checked_before_geometry() { + build_variable_overlay_iter( + [[IntStrokeVertex::new(IntPoint::new(i32::MAX, 0), 0)]], + IntVariableStrokeStyle::new().math(MathMode::Float), + Default::default(), + #[cfg(feature = "variable_stroke_debug")] + None, + ); + } +} diff --git a/iOverlay/src/mesh/int/variable_stroke/builder.rs b/iOverlay/src/mesh/int/variable_stroke/builder.rs index 37471098..7c837205 100644 --- a/iOverlay/src/mesh/int/variable_stroke/builder.rs +++ b/iOverlay/src/mesh/int/variable_stroke/builder.rs @@ -1,581 +1,134 @@ #[cfg(feature = "variable_stroke_debug")] use super::debug::{IntVariableStrokeDebugEdge, VariableStrokeDebugEdgeKind}; use super::{ - section::{RadiusTrend, Section}, + builder_join::{Cap, SegmentBuilder}, + math::VariableStrokeMath, + section::Section, style::{IntStrokeVertex, IntVariableStrokeStyle}, }; -use crate::mesh::{ - int::{ - arc::{ArcBuilder, ArcDirection}, - math::{direction, point, scaled_point}, - }, - subject::SubjectSegments, -}; +use crate::mesh::int::{arc::ArcMath, math::integer::IntegerMath}; use crate::segm::{boolean::ShapeCountBoolean, segment::Segment}; use alloc::vec::Vec; -use i_float::int::{ - angle::Angle, - number::{int::IntNumber, uint::UIntNumber, wide_int::WideIntNumber}, - point::IntPoint, -}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Cap { - Butt, - Round, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ArcSweep { - Minor, - Major, -} +use i_float::int::number::int::IntNumber; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct SubSegment { - start: usize, - end: usize, - start_cap: Cap, - end_cap: Cap, +pub(super) struct VariableStrokeBuilder = IntegerMath> { + arc: M::Arc, } -pub(super) struct VariableStrokeBuilder { - arc: ArcBuilder, -} - -impl VariableStrokeBuilder { +impl> VariableStrokeBuilder { pub(super) fn new(style: IntVariableStrokeStyle) -> Self { Self { - arc: ArcBuilder::new(style.arc), + arc: M::Arc::new(style.arc), } } pub(super) fn build( &mut self, - path: &[IntStrokeVertex], + path: impl IntoIterator>, segments: &mut Vec>, + #[cfg(feature = "variable_stroke_debug")] debug_edges: Option< + &mut Vec>, + >, + #[cfg(feature = "variable_stroke_debug")] path_index: usize, ) { - if path.is_empty() { - return; - } - - let subsegments = Self::find_subsegments(path); - let mut output = SegmentBuilder { + let mut output = SegmentBuilder:: { arc: &mut self.arc, segments, #[cfg(feature = "variable_stroke_debug")] - debug_edges: None, + debug_edges, #[cfg(feature = "variable_stroke_debug")] - debug_path_index: 0, - }; - - for subsegment in subsegments.iter() { - Self::add_subsegment(subsegment, path, &mut output); - } - } - - #[cfg(feature = "variable_stroke_debug")] - pub(super) fn build_debug( - &mut self, - path: &[IntStrokeVertex], - path_index: usize, - segments: &mut Vec>, - debug_edges: &mut Vec>, - ) { - if path.is_empty() { - return; - } - - let subsegments = Self::find_subsegments(path); - let mut output = SegmentBuilder { - arc: &mut self.arc, - segments, - debug_edges: Some(debug_edges), debug_path_index: path_index, }; - - for subsegment in subsegments.iter() { - Self::add_subsegment(subsegment, path, &mut output); - } - } - - fn add_subsegment(subsegment: &SubSegment, path: &[IntStrokeVertex], output: &mut SegmentBuilder) { - if subsegment.start == subsegment.end { - if subsegment.start_cap != Cap::Butt || subsegment.end_cap != Cap::Butt { - let vertex = &path[subsegment.start]; - output.add_circle( - &vertex.point, - vertex.radius(), - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::CircleArc, - ); - } - return; - } - - let mut sections = (subsegment.start..subsegment.end) - .filter_map(|index| Section::try_new(&path[index], &path[index + 1])); - let Some(mut previous) = sections.next() else { + let mut path = path.into_iter(); + let Some(mut a) = path.next() else { return; }; - - output.add_section(&previous); - output.add_start_cap(&previous, subsegment.start_cap); - - for section in sections { - output.add_section(§ion); - output.add_join(&previous, §ion); - previous = section; - } - - output.add_end_cap(&previous, subsegment.end_cap); - } - - fn find_subsegments(path: &[IntStrokeVertex]) -> Vec { - if path.is_empty() { - return Vec::new(); - } - - let mut result = Vec::new(); - let mut start = 0; - let mut start_cap = Cap::Round; - let mut final_end_cap = Cap::Round; - - for (index, pair) in path.windows(2).enumerate() { - final_end_cap = Cap::Round; - - if let Some((end_cap, next_start_cap)) = Self::break_caps(&pair[0], &pair[1]) { - result.push(SubSegment { - start, - end: index, - start_cap, - end_cap, - }); - - start = index + 1; - start_cap = next_start_cap; - continue; - } - - if index > 0 && Self::circle_is_covered_by_section(&path[index - 1], &pair[0], &pair[1]) { - result.push(SubSegment { - start, - end: index, - start_cap, - end_cap: Cap::Round, - }); - - start = index; - start_cap = Cap::Butt; - final_end_cap = Cap::Butt; - } - } - - result.push(SubSegment { - start, - end: path.len() - 1, - start_cap, - end_cap: final_end_cap, - }); - result - } - - fn break_caps(a: &IntStrokeVertex, b: &IntStrokeVertex) -> Option<(Cap, Cap)> { - let int_a = a.point; - let int_b = b.point; - let a_radius = a.radius(); - let b_radius = b.radius(); - let radius_delta = a_radius.to_wide() - b_radius.to_wide(); - let distance_sqr = (int_b - int_a).sqr_length(); - - if (radius_delta * radius_delta).to_uint() < distance_sqr { - return None; - } - - if a_radius >= b_radius { - Some((Cap::Round, Cap::Butt)) - } else { - Some((Cap::Butt, Cap::Round)) - } - } - - fn circle_is_covered_by_section( - a: &IntStrokeVertex, - b: &IntStrokeVertex, - c: &IntStrokeVertex, - ) -> bool { - let a_radius = a.radius(); - let b_radius = b.radius(); - let c_radius = c.radius(); - if a_radius.max(b_radius) <= c_radius { - return false; - } - - let Some(section) = Section::try_new(a, b) else { - return false; - }; - - let points = [section.a_left, section.b_left, section.b_right, section.a_right]; - let center = c.point; - let radius = c_radius.to_wide().to_uint(); - let first_edge = points[1] - points[0]; - let orientation = first_edge.cross_product(points[2] - points[1]); - if orientation == I::Wide::ZERO { - return false; - } - - for index in 0..points.len() { - let a = points[index]; - let b = points[(index + 1) % points.len()]; - let edge = b - a; - let side = edge.cross_product(center - a); - let interior_distance = if orientation > I::Wide::ZERO { side } else { -side }; - if interior_distance < I::Wide::ZERO { - return false; - } - - let length_sqr = edge.sqr_length(); - let mut length = length_sqr.isqrt(); - if length * length < length_sqr { - length += I::WideUInt::ONE; - } - if interior_distance.to_uint() < radius * length { - return false; + let mut chain = Chain::new(a, Cap::Round); + let mut previous_section: Option> = None; + let mut previous_radius = I::ZERO; + let mut end_cap = Cap::Round; + + for b in path { + end_cap = Cap::Round; + let section = Section::try_new::(&a, &b); + if let Some((end, start)) = break_caps(&a, &b) { + chain.finish(end, &mut output); + chain = Chain::new(b, start); + } else { + if previous_radius > b.radius() + && previous_section.is_some_and(|s| s.covers_circle(b.point, b.radius())) + { + chain.finish(Cap::Round, &mut output); + chain = Chain::new(a, Cap::Butt); + end_cap = Cap::Butt; + } + chain.has_edge = true; + if let Some(section) = section { + output.add_section(§ion); + if let Some(previous) = chain.last { + output.add_join(&previous, §ion); + } else { + output.add_start_cap(§ion, chain.start_cap); + } + chain.last = Some(section); + } } + previous_section = section; + previous_radius = a.radius().max(b.radius()); + a = b; } - - true + chain.finish(end_cap, &mut output); } } -struct SegmentBuilder<'a, I: IntNumber> { - arc: &'a mut ArcBuilder, - segments: &'a mut Vec>, - #[cfg(feature = "variable_stroke_debug")] - debug_edges: Option<&'a mut Vec>>, - #[cfg(feature = "variable_stroke_debug")] - debug_path_index: usize, +struct Chain { + start: IntStrokeVertex, + start_cap: Cap, + last: Option>, + has_edge: bool, } -impl SegmentBuilder<'_, I> { - fn add_circle( - &mut self, - center: &IntPoint, - radius: I, - #[cfg(feature = "variable_stroke_debug")] kind: VariableStrokeDebugEdgeKind, - ) { - if radius <= I::ONE { - return; - } - let right = point(*center, radius.to_wide(), I::Wide::ZERO); - let left = point(*center, -radius.to_wide(), I::Wide::ZERO); - self.arc_edges( - center, - &right, - &left, - radius, - #[cfg(feature = "variable_stroke_debug")] - kind, - ); - self.arc_edges( - center, - &left, - &right, - radius, - #[cfg(feature = "variable_stroke_debug")] - kind, - ); - } - - #[inline] - fn add_section(&mut self, section: &Section) { - self.add_edge( - §ion.b_left, - §ion.a_left, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::SectionBoundary, - ); - self.add_edge( - §ion.a_right, - §ion.b_right, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::SectionBoundary, - ); - } - - fn add_join(&mut self, prev: &Section, next: &Section) -> usize { - let prev_center = prev.b; - let next_center = next.a; - if prev_center != next_center { - // A non-drawable section between these sections was filtered out. They belong to - // separate chains, so close both chains instead of building an arc between centers. - self.add_end_cap(prev, Cap::Butt); - self.add_start_cap(next, Cap::Butt); - return 0; - } - - let prev_a_left = prev.a_left; - let prev_b_left = prev.b_left; - let prev_a_right = prev.a_right; - let prev_b_right = prev.b_right; - let next_a_left = next.a_left; - let next_b_left = next.b_left; - let next_a_right = next.a_right; - let next_b_right = next.b_right; - - let prev_left = prev_b_left - prev_a_left; - let prev_right = prev_b_right - prev_a_right; - let next_left = next_b_left - next_a_left; - let next_right = next_b_right - next_a_right; - - let mut arc_count = 0; - let left_cross = next_left.cross_product(prev_left); - - let right_cross = prev_right.cross_product(next_right); - - let prev_a = prev.a; - let prev_b = prev_center; - let next_a = next.a; - let next_b = next.b; - - let prev_middle = prev_b - prev_a; - let next_middle = next_b - next_a; - - let middle_cross = prev_middle.cross_product(next_middle); - - let left_arc = left_cross > I::Wide::ZERO || middle_cross < I::Wide::ZERO; - let right_arc = right_cross > I::Wide::ZERO || middle_cross >= I::Wide::ZERO; - - if left_arc { - arc_count += self.add_arc_ccw( - &prev.b, - &next.a_left, - &prev.b_left, - ArcSweep::Minor, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::JoinArc, - ) as usize; - } else { - self.add_edge( - &next.a_left, - &prev.b_left, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::JoinClosure, - ); - } - - if right_arc { - arc_count += self.add_arc_ccw( - &prev.b, - &prev.b_right, - &next.a_right, - ArcSweep::Major, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::JoinArc, - ) as usize; - } else { - self.add_edge( - &prev.b_right, - &next.a_right, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::JoinClosure, - ); - } - - arc_count - } - - fn add_start_cap(&mut self, section: &Section, cap: Cap) { - match cap { - Cap::Butt => self.add_edge( - §ion.a_left, - §ion.a_right, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::CapClosure, - ), - Cap::Round => { - let sweep = if section.radius_trend == RadiusTrend::Decreasing { - ArcSweep::Major - } else { - ArcSweep::Minor - }; - self.add_arc_ccw( - §ion.a, - §ion.a_left, - §ion.a_right, - sweep, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::CapArc, - ); - } - } - } - - fn add_end_cap(&mut self, section: &Section, cap: Cap) { - match cap { - Cap::Butt => self.add_edge( - §ion.b_right, - §ion.b_left, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::CapClosure, - ), - Cap::Round => { - let sweep = if section.radius_trend == RadiusTrend::Increasing { - ArcSweep::Major - } else { - ArcSweep::Minor - }; - self.add_arc_ccw( - §ion.b, - §ion.b_right, - §ion.b_left, - sweep, - #[cfg(feature = "variable_stroke_debug")] - VariableStrokeDebugEdgeKind::CapArc, - ); - } - } - } - - fn arc_sweep_ccw( - &self, - center: &IntPoint, - from: &IntPoint, - to: &IntPoint, - aligned_sweep: ArcSweep, - ) -> ArcSweep { - let center = *center; - let from_vector = *from - center; - let to_vector = *to - center; - let cross = from_vector.cross_product(to_vector); - - if cross > I::Wide::ZERO { - ArcSweep::Minor - } else if cross < I::Wide::ZERO { - ArcSweep::Major - } else if from_vector.dot_product(to_vector) < I::Wide::ZERO { - // Both choices describe the same half-circle. - ArcSweep::Minor - } else { - // Coincident directions can mean either a collapsed minor arc or a full major arc. - aligned_sweep +impl Chain { + fn new(start: IntStrokeVertex, start_cap: Cap) -> Self { + Self { + start, + start_cap, + last: None, + has_edge: false, } } - fn add_arc_ccw( - &mut self, - center: &IntPoint, - from: &IntPoint, - to: &IntPoint, - aligned_sweep: ArcSweep, - #[cfg(feature = "variable_stroke_debug")] edge_kind: VariableStrokeDebugEdgeKind, - ) -> bool { - let sweep = self.arc_sweep_ccw(center, from, to, aligned_sweep); - if sweep == ArcSweep::Minor && *from == *to { - return false; - } - - let Some(from_unit) = direction(*from - *center) else { - return false; - }; - let Some(to_unit) = direction(*to - *center) else { - return false; - }; - let from_vector = *from - *center; - let radius = I::from_uint(from_vector.sqr_length().isqrt()); - if Angle::between(from_unit, to_unit).bits() == 0 && sweep == ArcSweep::Major { - self.add_circle( - center, - radius, - #[cfg(feature = "variable_stroke_debug")] - edge_kind, - ); - self.add_edge( - from, - to, + fn finish>(&self, end_cap: Cap, output: &mut SegmentBuilder) { + if let Some(last) = self.last { + output.add_end_cap(&last, end_cap); + } else if !self.has_edge && (self.start_cap != Cap::Butt || end_cap != Cap::Butt) { + output.add_circle( + &self.start.point, + self.start.radius(), #[cfg(feature = "variable_stroke_debug")] - edge_kind, - ); - } else { - self.arc_edges( - center, - from, - to, - radius, - #[cfg(feature = "variable_stroke_debug")] - edge_kind, + VariableStrokeDebugEdgeKind::CircleArc, ); } - true - } - - fn arc_edges( - &mut self, - center: &IntPoint, - from: &IntPoint, - to: &IntPoint, - radius: I, - #[cfg(feature = "variable_stroke_debug")] kind: VariableStrokeDebugEdgeKind, - ) { - let Some(a) = direction(*from - *center) else { - return; - }; - let Some(b) = direction(*to - *center) else { - return; - }; - let directions = self.arc.build(a, b, ArcDirection::Counterclockwise); - let mut previous = *from; - // Split borrows: the reusable arc buffer stays borrowed while emitting. - for &dir in directions { - let next = scaled_point(*center, dir, radius); - if previous != next { - #[cfg(feature = "variable_stroke_debug")] - if let Some(edges) = self.debug_edges.as_mut() { - edges.push(IntVariableStrokeDebugEdge { - a: previous, - b: next, - kind, - path_index: self.debug_path_index, - order: edges.len(), - }); - } - self.segments.push_non_degenerate(previous, next); - } - previous = next; - } - self.add_edge( - &previous, - to, - #[cfg(feature = "variable_stroke_debug")] - kind, - ); } +} - #[inline] - fn add_edge( - &mut self, - a: &IntPoint, - b: &IntPoint, - #[cfg(feature = "variable_stroke_debug")] kind: VariableStrokeDebugEdgeKind, - ) { - let a = *a; - let b = *b; - if a != b { - #[cfg(feature = "variable_stroke_debug")] - if let Some(debug_edges) = self.debug_edges.as_mut() { - debug_edges.push(IntVariableStrokeDebugEdge { - a, - b, - kind, - path_index: self.debug_path_index, - order: debug_edges.len(), - }); - } - self.segments.push(Segment::subject(a, b)); - } +fn break_caps(a: &IntStrokeVertex, b: &IntStrokeVertex) -> Option<(Cap, Cap)> { + use i_float::int::number::wide_int::WideIntNumber; + let ra = a.radius(); + let rb = b.radius(); + let delta = ra.to_wide() - rb.to_wide(); + if (delta * delta).to_uint() < (b.point - a.point).sqr_length() { + None + } else if ra >= rb { + Some((Cap::Round, Cap::Butt)) + } else { + Some((Cap::Butt, Cap::Round)) } } +#[cfg(test)] +#[path = "tests_reference.rs"] +mod reference; #[cfg(test)] #[path = "tests.rs"] mod tests; diff --git a/iOverlay/src/mesh/int/variable_stroke/builder_join.rs b/iOverlay/src/mesh/int/variable_stroke/builder_join.rs new file mode 100644 index 00000000..68341b47 --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/builder_join.rs @@ -0,0 +1,356 @@ +#[cfg(feature = "variable_stroke_debug")] +use super::debug::{IntVariableStrokeDebugEdge, VariableStrokeDebugEdgeKind}; +use super::section::{RadiusTrend, Section}; +use crate::mesh::{ + int::{ + arc::{ArcDirection, ArcMath}, + math::{backend::MeshMath, integer::IntegerMath, point, scaled_point}, + }, + subject::SubjectSegments, +}; +use crate::segm::{boolean::ShapeCountBoolean, segment::Segment}; +use alloc::vec::Vec; +use i_float::int::{ + number::{int::IntNumber, uint::UIntNumber, wide_int::WideIntNumber}, + point::IntPoint, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Cap { + Butt, + Round, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ArcSweep { + Minor, + Major, +} + +pub(super) struct SegmentBuilder<'a, I: IntNumber, M: MeshMath = IntegerMath> { + pub(super) arc: &'a mut M::Arc, + pub(super) segments: &'a mut Vec>, + #[cfg(feature = "variable_stroke_debug")] + pub(super) debug_edges: Option<&'a mut Vec>>, + #[cfg(feature = "variable_stroke_debug")] + pub(super) debug_path_index: usize, +} + +impl> SegmentBuilder<'_, I, M> { + pub(super) fn add_circle( + &mut self, + center: &IntPoint, + radius: I, + #[cfg(feature = "variable_stroke_debug")] kind: VariableStrokeDebugEdgeKind, + ) { + if radius <= I::ONE { + return; + } + let right = point(*center, radius.to_wide(), I::Wide::ZERO); + let left = point(*center, -radius.to_wide(), I::Wide::ZERO); + self.arc_edges( + center, + &right, + &left, + radius, + #[cfg(feature = "variable_stroke_debug")] + kind, + ); + self.arc_edges( + center, + &left, + &right, + radius, + #[cfg(feature = "variable_stroke_debug")] + kind, + ); + } + + #[inline] + pub(super) fn add_section(&mut self, section: &Section) { + self.add_edge( + §ion.b_left, + §ion.a_left, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::SectionBoundary, + ); + self.add_edge( + §ion.a_right, + §ion.b_right, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::SectionBoundary, + ); + } + + pub(super) fn add_join(&mut self, prev: &Section, next: &Section) -> usize { + let prev_center = prev.b; + let next_center = next.a; + if prev_center != next_center { + // A non-drawable section between these sections was filtered out. They belong to + // separate chains, so close both chains instead of building an arc between centers. + self.add_end_cap(prev, Cap::Butt); + self.add_start_cap(next, Cap::Butt); + return 0; + } + + let prev_a_left = prev.a_left; + let prev_b_left = prev.b_left; + let prev_a_right = prev.a_right; + let prev_b_right = prev.b_right; + let next_a_left = next.a_left; + let next_b_left = next.b_left; + let next_a_right = next.a_right; + let next_b_right = next.b_right; + + let prev_left = prev_b_left - prev_a_left; + let prev_right = prev_b_right - prev_a_right; + let next_left = next_b_left - next_a_left; + let next_right = next_b_right - next_a_right; + + let mut arc_count = 0; + let left_cross = next_left.cross_product(prev_left); + + let right_cross = prev_right.cross_product(next_right); + + let prev_a = prev.a; + let prev_b = prev_center; + let next_a = next.a; + let next_b = next.b; + + let prev_middle = prev_b - prev_a; + let next_middle = next_b - next_a; + + let middle_cross = prev_middle.cross_product(next_middle); + + let left_arc = left_cross > I::Wide::ZERO || middle_cross < I::Wide::ZERO; + let right_arc = right_cross > I::Wide::ZERO || middle_cross >= I::Wide::ZERO; + + if left_arc { + arc_count += self.add_arc_ccw( + &prev.b, + &next.a_left, + &prev.b_left, + ArcSweep::Minor, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::JoinArc, + ) as usize; + } else { + self.add_edge( + &next.a_left, + &prev.b_left, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::JoinClosure, + ); + } + + if right_arc { + arc_count += self.add_arc_ccw( + &prev.b, + &prev.b_right, + &next.a_right, + ArcSweep::Major, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::JoinArc, + ) as usize; + } else { + self.add_edge( + &prev.b_right, + &next.a_right, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::JoinClosure, + ); + } + + arc_count + } + + pub(super) fn add_start_cap(&mut self, section: &Section, cap: Cap) { + match cap { + Cap::Butt => self.add_edge( + §ion.a_left, + §ion.a_right, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::CapClosure, + ), + Cap::Round => { + let sweep = if section.radius_trend == RadiusTrend::Decreasing { + ArcSweep::Major + } else { + ArcSweep::Minor + }; + self.add_arc_ccw( + §ion.a, + §ion.a_left, + §ion.a_right, + sweep, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::CapArc, + ); + } + } + } + + pub(super) fn add_end_cap(&mut self, section: &Section, cap: Cap) { + match cap { + Cap::Butt => self.add_edge( + §ion.b_right, + §ion.b_left, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::CapClosure, + ), + Cap::Round => { + let sweep = if section.radius_trend == RadiusTrend::Increasing { + ArcSweep::Major + } else { + ArcSweep::Minor + }; + self.add_arc_ccw( + §ion.b, + §ion.b_right, + §ion.b_left, + sweep, + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::CapArc, + ); + } + } + } + + pub(super) fn arc_sweep_ccw( + &self, + center: &IntPoint, + from: &IntPoint, + to: &IntPoint, + aligned_sweep: ArcSweep, + ) -> ArcSweep { + let center = *center; + let from_vector = *from - center; + let to_vector = *to - center; + let cross = from_vector.cross_product(to_vector); + + if cross > I::Wide::ZERO { + ArcSweep::Minor + } else if cross < I::Wide::ZERO { + ArcSweep::Major + } else if from_vector.dot_product(to_vector) < I::Wide::ZERO { + // Both choices describe the same half-circle. + ArcSweep::Minor + } else { + // Coincident directions can mean either a collapsed minor arc or a full major arc. + aligned_sweep + } + } + + pub(super) fn add_arc_ccw( + &mut self, + center: &IntPoint, + from: &IntPoint, + to: &IntPoint, + aligned_sweep: ArcSweep, + #[cfg(feature = "variable_stroke_debug")] edge_kind: VariableStrokeDebugEdgeKind, + ) -> bool { + let sweep = self.arc_sweep_ccw(center, from, to, aligned_sweep); + if sweep == ArcSweep::Minor && *from == *to { + return false; + } + + let Some(from_unit) = M::normalize(*from - *center) else { + return false; + }; + let Some(to_unit) = M::normalize(*to - *center) else { + return false; + }; + let from_vector = *from - *center; + let radius = I::from_uint(from_vector.sqr_length().isqrt()); + if M::angle_between(from_unit, to_unit).bits() == 0 && sweep == ArcSweep::Major { + self.add_circle( + center, + radius, + #[cfg(feature = "variable_stroke_debug")] + edge_kind, + ); + self.add_edge( + from, + to, + #[cfg(feature = "variable_stroke_debug")] + edge_kind, + ); + } else { + self.arc_edges( + center, + from, + to, + radius, + #[cfg(feature = "variable_stroke_debug")] + edge_kind, + ); + } + true + } + + pub(super) fn arc_edges( + &mut self, + center: &IntPoint, + from: &IntPoint, + to: &IntPoint, + radius: I, + #[cfg(feature = "variable_stroke_debug")] kind: VariableStrokeDebugEdgeKind, + ) { + let Some(a) = M::normalize(*from - *center) else { + return; + }; + let Some(b) = M::normalize(*to - *center) else { + return; + }; + let directions = self.arc.build(a, b, ArcDirection::Counterclockwise); + let mut previous = *from; + // Split borrows: the reusable arc buffer stays borrowed while emitting. + for &dir in directions { + let next = scaled_point(*center, dir, radius); + if previous != next { + #[cfg(feature = "variable_stroke_debug")] + if let Some(edges) = self.debug_edges.as_mut() { + edges.push(IntVariableStrokeDebugEdge { + a: previous, + b: next, + kind, + path_index: self.debug_path_index, + order: edges.len(), + }); + } + self.segments.push_non_degenerate(previous, next); + } + previous = next; + } + self.add_edge( + &previous, + to, + #[cfg(feature = "variable_stroke_debug")] + kind, + ); + } + + #[inline] + pub(super) fn add_edge( + &mut self, + a: &IntPoint, + b: &IntPoint, + #[cfg(feature = "variable_stroke_debug")] kind: VariableStrokeDebugEdgeKind, + ) { + let a = *a; + let b = *b; + if a != b { + #[cfg(feature = "variable_stroke_debug")] + if let Some(debug_edges) = self.debug_edges.as_mut() { + debug_edges.push(IntVariableStrokeDebugEdge { + a, + b, + kind, + path_index: self.debug_path_index, + order: debug_edges.len(), + }); + } + self.segments.push(Segment::subject(a, b)); + } + } +} diff --git a/iOverlay/src/mesh/int/variable_stroke/math.rs b/iOverlay/src/mesh/int/variable_stroke/math.rs new file mode 100644 index 00000000..2251a87e --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/math.rs @@ -0,0 +1,108 @@ +use crate::mesh::int::math::{backend::MeshMath, float::FloatMath, integer::IntegerMath, mul_div}; +use i_float::float::number::FloatNumber; +use i_float::int::{ + number::{int::IntNumber, uint::UIntNumber, wide_int::WideIntNumber}, + vector::IntVector, +}; + +/// Construction of the external tangent contacts of two non-containing circles. +pub(super) trait VariableStrokeMath: MeshMath { + /// Offsets from the centers, ordered as a-left, a-right, b-left, b-right. + /// The caller guarantees |ra - rb|² < |v|². + fn tangent_offsets(v: IntVector, ra: I, rb: I) -> [IntVector; 4]; +} + +impl VariableStrokeMath for IntegerMath { + fn tangent_offsets(v: IntVector, ra: I, rb: I) -> [IntVector; 4] { + let delta = ra.to_wide() - rb.to_wide(); + let sq = v.sqr_length(); + // Keep fractional bits of sqrt(length² - delta²). Taking an unscaled + // integer root would noticeably shrink wide strokes on short sections. + // Using sq to choose the shift also bounds every numerator by sq << shift. + let shift = (sq.leading_zeros() - 1) / 2; + let tangent = I::Wide::from_uint(((sq - (delta * delta).to_uint()) << (2 * shift)).isqrt()); + let denominator = I::Wide::from_uint(sq << shift); + let dx = (delta * v.x) << shift; + let dy = (delta * v.y) << shift; + let left_x = dx - tangent * v.y; + let left_y = dy + tangent * v.x; + let right_x = dx + tangent * v.y; + let right_y = dy - tangent * v.x; + let contact = |r: I, x, y| { + IntVector::new( + mul_div::(r.to_wide(), x, denominator), + mul_div::(r.to_wide(), y, denominator), + ) + }; + [ + contact(ra, left_x, left_y), + contact(ra, right_x, right_y), + contact(rb, left_x, left_y), + contact(rb, right_x, right_y), + ] + } +} + +impl VariableStrokeMath for FloatMath { + fn tangent_offsets(v: IntVector, ra: I, rb: I) -> [IntVector; 4] { + let delta = ra.to_wide() - rb.to_wide(); + let sq = v.sqr_length(); + // Subtract before converting to float: nearly contained circles can + // have a tiny positive difference even at large integer coordinates. + let tangent = FloatNumber::sqrt((sq - (delta * delta).to_uint()).to_f64()); + let denominator = sq.to_f64(); + let dx = (delta * v.x).to_f64(); + let dy = (delta * v.y).to_f64(); + let tx = tangent * v.x.to_f64(); + let ty = tangent * v.y.to_f64(); + let contact = |r: I, x: f64, y: f64| { + let scale = r.to_f64() / denominator; + IntVector::new( + I::Wide::from_rounded_float(scale * x), + I::Wide::from_rounded_float(scale * y), + ) + }; + [ + contact(ra, dx - ty, dy + tx), + contact(ra, dx + ty, dy - tx), + contact(rb, dx - ty, dy + tx), + contact(rb, dx + ty, dy - tx), + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn float_tangents_keep_the_small_difference_near_containment() { + let d = 1_i64 << 59; + let [al, ar, _, _] = FloatMath::tangent_offsets(IntVector::::new(d as i128, 1), d + 2, 2); + assert_eq!(al.y, 2); + assert_eq!(ar.y, 0); + } + + #[test] + fn both_backends_preserve_short_section_contact_precision() { + fn check>() { + for (x, y) in [(1, 1), (2, 2), (3, 2), (1, 7)] { + for delta in [0, 1] { + let offsets = M::tangent_offsets(IntVector::new(x, y), 4096, 4096 - delta); + let sq = (x * x + y * y) as f64; + let tangent = (sq - (delta * delta) as f64).sqrt(); + for (i, p) in offsets.iter().enumerate() { + let radius = if i < 2 { 4096.0 } else { (4096 - delta) as f64 }; + let sign = if i % 2 == 0 { 1.0 } else { -1.0 }; + let ex = radius * (delta as f64 * x as f64 - sign * tangent * y as f64) / sq; + let ey = radius * (delta as f64 * y as f64 + sign * tangent * x as f64) / sq; + assert!((p.x as f64 - ex).abs() <= 0.51); + assert!((p.y as f64 - ey).abs() <= 0.51); + } + } + } + } + check::(); + check::(); + } +} diff --git a/iOverlay/src/mesh/int/variable_stroke/mod.rs b/iOverlay/src/mesh/int/variable_stroke/mod.rs index af1e74f8..78744e80 100644 --- a/iOverlay/src/mesh/int/variable_stroke/mod.rs +++ b/iOverlay/src/mesh/int/variable_stroke/mod.rs @@ -1,6 +1,9 @@ +pub(crate) mod build; mod builder; +mod builder_join; #[cfg(feature = "variable_stroke_debug")] pub mod debug; +mod math; mod resource; mod section; mod style; diff --git a/iOverlay/src/mesh/int/variable_stroke/offset.rs b/iOverlay/src/mesh/int/variable_stroke/offset.rs index f2a7aeb2..c17ea5b2 100644 --- a/iOverlay/src/mesh/int/variable_stroke/offset.rs +++ b/iOverlay/src/mesh/int/variable_stroke/offset.rs @@ -1,11 +1,14 @@ -use super::{IntVariableStrokeSource, IntVariableStrokeStyle, builder::VariableStrokeBuilder}; +#[cfg(feature = "variable_stroke_debug")] +use super::build::build_variable_overlay_iter; +use super::{IntVariableStrokeSource, IntVariableStrokeStyle, build::BuildVariableOverlay}; use crate::core::{ - fill_rule::FillRule, - integer::OverlayInt, - overlay::{IntOverlayOptions, Overlay}, - overlay_rule::OverlayRule, + fill_rule::FillRule, integer::OverlayInt, overlay::IntOverlayOptions, overlay_rule::OverlayRule, }; -use crate::mesh::int::outline::offset::IntOutlineError; +use crate::mesh::int::{ + math::{backend::MeshMath, float::FloatMath}, + outline::offset::IntOutlineError, +}; +#[cfg(feature = "variable_stroke_debug")] use alloc::vec::Vec; use i_float::int::rect::IntRect; use i_shape::{flat::buffer::FlatContoursBuffer, int::shape::IntShapes}; @@ -26,6 +29,7 @@ pub type IntVariableStrokeError = IntOutlineError; /// assert_eq!(path.variable_stroke(IntVariableStrokeStyle::new()).unwrap().len(), 1); /// ``` pub trait IntVariableStrokeOffset: IntVariableStrokeSource { + /// Uses conservative padding valid for either construction math mode. fn validate_variable_stroke(&self) -> Result<(), IntVariableStrokeError> { let radius = self .iter_variable_paths() @@ -34,7 +38,8 @@ pub trait IntVariableStrokeOffset: IntVariableStrokeSource { .max() .unwrap_or(I::ZERO); if let Some(rect) = IntRect::with_iter(self.iter_variable_paths().flatten().map(|v| &v.point)) { - if !crate::mesh::int::bounds::expanded_is_safe(rect, radius.to_wide()) { + let padding = >::guard_padding(radius.to_wide()); + if !crate::mesh::int::bounds::expanded_is_safe(rect, padding) { return Err(IntVariableStrokeError::CoordinateOutOfRange); } } @@ -78,18 +83,13 @@ pub trait IntVariableStrokeOffset: IntVariableStrokeSource { style: IntVariableStrokeStyle, options: IntOverlayOptions, ) -> Result, IntVariableStrokeError> { - debug_assert!( - self.validate_variable_stroke().is_ok(), - "variable stroke exceeds coordinate range" - ); - let mut builder = VariableStrokeBuilder::new(style); - let mut segments = Vec::new(); let mut edges = Vec::new(); - for (index, path) in self.iter_variable_paths().enumerate() { - builder.build_debug(path, index, &mut segments, &mut edges); - } - let mut overlay = Overlay::with_segments(segments); - overlay.options = options; + let mut overlay = build_variable_overlay_iter( + self.iter_variable_paths().map(|path| path.iter().copied()), + style, + options, + Some(&mut edges), + ); Ok(super::debug::IntVariableStrokeDebugResult { edges, shapes: overlay.overlay(OverlayRule::Subject, FillRule::Positive), @@ -97,24 +97,3 @@ pub trait IntVariableStrokeOffset: IntVariableStrokeSource { } } impl + ?Sized> IntVariableStrokeOffset for S {} -trait BuildVariableOverlay: IntVariableStrokeOffset { - fn build_variable_overlay( - &self, - style: IntVariableStrokeStyle, - options: IntOverlayOptions, - ) -> Overlay { - debug_assert!( - self.validate_variable_stroke().is_ok(), - "variable stroke exceeds coordinate range" - ); - let mut builder = VariableStrokeBuilder::new(style); - let mut segments = Vec::new(); - for path in self.iter_variable_paths() { - builder.build(path, &mut segments); - } - let mut overlay = Overlay::with_segments(segments); - overlay.options = options; - overlay - } -} -impl + ?Sized> BuildVariableOverlay for S {} diff --git a/iOverlay/src/mesh/int/variable_stroke/section.rs b/iOverlay/src/mesh/int/variable_stroke/section.rs index 919d6384..4e3d621f 100644 --- a/iOverlay/src/mesh/int/variable_stroke/section.rs +++ b/iOverlay/src/mesh/int/variable_stroke/section.rs @@ -1,5 +1,6 @@ +use super::math::VariableStrokeMath; use super::style::IntStrokeVertex; -use crate::mesh::int::math::{mul_div, point}; +use crate::mesh::int::math::point; use i_float::int::{ number::{int::IntNumber, uint::UIntNumber, wide_int::WideIntNumber}, point::IntPoint, @@ -22,7 +23,10 @@ pub(super) struct Section { pub(super) radius_trend: RadiusTrend, } impl Section { - pub(super) fn try_new(a: &IntStrokeVertex, b: &IntStrokeVertex) -> Option { + pub(super) fn try_new>( + a: &IntStrokeVertex, + b: &IntStrokeVertex, + ) -> Option { let ra = a.radius(); let rb = b.radius(); if a.point == b.point || ra.max(rb) <= I::ONE { @@ -34,32 +38,14 @@ impl Section { if (delta * delta).to_uint() >= sq { return None; } - // Keep fractional bits of sqrt(length² - delta²). Taking an unscaled - // integer root would noticeably shrink wide strokes on short sections. - // Using sq to choose the shift also bounds every numerator by sq << shift. - let shift = (sq.leading_zeros() - 1) / 2; - let tangent = I::Wide::from_uint(((sq - (delta * delta).to_uint()) << (2 * shift)).isqrt()); - let denominator = I::Wide::from_uint(sq << shift); - let dx = (delta * v.x) << shift; - let dy = (delta * v.y) << shift; - let left_x = dx - tangent * v.y; - let left_y = dy + tangent * v.x; - let right_x = dx + tangent * v.y; - let right_y = dy - tangent * v.x; - let contact = |p, r: I, x, y| { - point( - p, - mul_div::(r.to_wide(), x, denominator), - mul_div::(r.to_wide(), y, denominator), - ) - }; + let [al, ar, bl, br] = M::tangent_offsets(v, ra, rb); Some(Self { a: a.point, b: b.point, - a_left: contact(a.point, ra, left_x, left_y), - a_right: contact(a.point, ra, right_x, right_y), - b_left: contact(b.point, rb, left_x, left_y), - b_right: contact(b.point, rb, right_x, right_y), + a_left: point(a.point, al.x, al.y), + a_right: point(a.point, ar.x, ar.y), + b_left: point(b.point, bl.x, bl.y), + b_right: point(b.point, br.x, br.y), radius_trend: if ra < rb { RadiusTrend::Increasing } else if ra > rb { @@ -69,4 +55,35 @@ impl Section { }, }) } + pub(super) fn covers_circle(&self, center: IntPoint, radius: I) -> bool { + let points = [self.a_left, self.b_left, self.b_right, self.a_right]; + let radius = radius.to_wide().to_uint(); + let first_edge = points[1] - points[0]; + let orientation = first_edge.cross_product(points[2] - points[1]); + if orientation == I::Wide::ZERO { + return false; + } + + for index in 0..points.len() { + let a = points[index]; + let b = points[(index + 1) % points.len()]; + let edge = b - a; + let side = edge.cross_product(center - a); + let interior_distance = if orientation > I::Wide::ZERO { side } else { -side }; + if interior_distance < I::Wide::ZERO { + return false; + } + + let length_sqr = edge.sqr_length(); + let mut length = length_sqr.isqrt(); + if length * length < length_sqr { + length += I::WideUInt::ONE; + } + if interior_distance.to_uint() < radius * length { + return false; + } + } + + true + } } diff --git a/iOverlay/src/mesh/int/variable_stroke/style.rs b/iOverlay/src/mesh/int/variable_stroke/style.rs index 2fadba5d..878b86fb 100644 --- a/iOverlay/src/mesh/int/variable_stroke/style.rs +++ b/iOverlay/src/mesh/int/variable_stroke/style.rs @@ -1,4 +1,5 @@ use crate::mesh::int::arc::ArcOptions; +use crate::mesh::math::MathMode; use i_float::int::{ number::{int::IntNumber, wide_int::WideIntNumber}, point::IntPoint, @@ -21,10 +22,13 @@ impl IntStrokeVertex { #[derive(Debug, Clone, Copy)] pub struct IntVariableStrokeStyle { pub arc: ArcOptions, + /// Arithmetic used to construct tangent contacts and arcs. + pub math: MathMode, } impl Default for IntVariableStrokeStyle { fn default() -> Self { Self { + math: MathMode::Integer, arc: ArcOptions { max_step: i_float::int::angle::Angle::from_bits(68_356_528), ..ArcOptions::default() @@ -36,6 +40,10 @@ impl IntVariableStrokeStyle { pub fn new() -> Self { Self::default() } + pub fn math(mut self, math: MathMode) -> Self { + self.math = math; + self + } pub fn arc(mut self, arc: ArcOptions) -> Self { self.arc = arc; self diff --git a/iOverlay/src/mesh/int/variable_stroke/tests.rs b/iOverlay/src/mesh/int/variable_stroke/tests.rs index c12e655e..a598de7e 100644 --- a/iOverlay/src/mesh/int/variable_stroke/tests.rs +++ b/iOverlay/src/mesh/int/variable_stroke/tests.rs @@ -1,6 +1,10 @@ +use super::super::builder_join::ArcSweep; +use super::reference::*; use super::*; +use crate::mesh::int::arc::ArcBuilder; use crate::mesh::int::arc::ArcOptions; use crate::mesh::int::variable_stroke::offset::IntVariableStrokeOffset; +use i_float::int::{angle::Angle, point::IntPoint}; fn vertex(x: i32, y: i32, width: i32) -> IntStrokeVertex { IntStrokeVertex::new(IntPoint::new(x, y), width) @@ -140,8 +144,8 @@ fn joins_preserve_contacts_and_select_all_exposed_arcs() { ), ]; for (path, expected) in cases { - let prev = Section::try_new(&path[0], &path[1]).unwrap(); - let next = Section::try_new(&path[1], &path[2]).unwrap(); + let prev = Section::try_new::(&path[0], &path[1]).unwrap(); + let next = Section::try_new::(&path[1], &path[2]).unwrap(); let mut arc = ArcBuilder::new(IntVariableStrokeStyle::default().arc); let mut segments = Vec::new(); assert_eq!(output(&mut arc, &mut segments).add_join(&prev, &next), expected); @@ -226,9 +230,16 @@ fn duplicates_and_small_widths_never_emit_zero_length_edges() { vertex(10, 0, 2 * width), vertex(end.0, end.1, width), ]; - let mut builder = VariableStrokeBuilder::new(IntVariableStrokeStyle::default()); + let mut builder = VariableStrokeBuilder::::new(IntVariableStrokeStyle::default()); let mut segments = Vec::new(); - builder.build(&path, &mut segments); + builder.build( + path, + &mut segments, + #[cfg(feature = "variable_stroke_debug")] + None, + #[cfg(feature = "variable_stroke_debug")] + 0, + ); assert!(segments.iter().all(|s| s.x_segment.a < s.x_segment.b)); if width >= 4 { assert!(!segments.is_empty()); @@ -243,7 +254,7 @@ fn short_sections_keep_tangent_precision_at_large_widths() { for delta in [0, 1] { let a = vertex(0, 0, 8192); let b = vertex(x, y, 8192 - 2 * delta); - let section = Section::try_new(&a, &b).unwrap(); + let section = Section::try_new::(&a, &b).unwrap(); let sq = f64::from(x * x + y * y); let tangent = (sq - f64::from(delta * delta)).sqrt(); for (left, p) in [(true, section.a_left), (false, section.a_right)] { @@ -262,3 +273,50 @@ fn short_sections_keep_tangent_precision_at_large_widths() { } } } + +#[test] +fn streaming_matches_original_partitioned_segments() { + use rand::{RngExt, SeedableRng, rngs::StdRng}; + let mut rng = StdRng::seed_from_u64(0x7365_6374_696f_6e73); + let style = IntVariableStrokeStyle::default(); + let mut builder = VariableStrokeBuilder::::new(style); + let mut arc = ArcBuilder::new(style.arc); + let mut actual = Vec::new(); + let mut expected = Vec::new(); + for _ in 0..2048 { + let mut path = Vec::new(); + for _ in 0..rng.random_range(0..24) { + let next = if !path.is_empty() && rng.random_range(0..5) == 0 { + let prev: IntStrokeVertex = path[path.len() - 1]; + vertex(prev.point.x, prev.point.y, rng.random_range(-2..20000)) + } else { + vertex( + rng.random_range(-10000..10000), + rng.random_range(-10000..10000), + rng.random_range(-2..20000), + ) + }; + path.push(next); + } + actual.clear(); + expected.clear(); + builder.build( + path.iter().copied(), + &mut actual, + #[cfg(feature = "variable_stroke_debug")] + None, + #[cfg(feature = "variable_stroke_debug")] + 0, + ); + for part in VariableStrokeBuilder::find_subsegments(&path) { + VariableStrokeBuilder::add_subsegment(&part, &path, &mut output(&mut arc, &mut expected)); + } + let signature = + |s: &Segment| (s.x_segment.a, s.x_segment.b, s.count.subj, s.count.clip); + assert_eq!( + actual.iter().map(signature).collect::>(), + expected.iter().map(signature).collect::>(), + "path={path:?}" + ); + } +} diff --git a/iOverlay/src/mesh/int/variable_stroke/tests_reference.rs b/iOverlay/src/mesh/int/variable_stroke/tests_reference.rs new file mode 100644 index 00000000..5c42d1df --- /dev/null +++ b/iOverlay/src/mesh/int/variable_stroke/tests_reference.rs @@ -0,0 +1,164 @@ +// The original partition-then-build traversal, retained only as a regression oracle. +use super::*; +use i_float::int::number::{uint::UIntNumber, wide_int::WideIntNumber}; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct SubSegment { + pub(super) start: usize, + pub(super) end: usize, + pub(super) start_cap: Cap, + pub(super) end_cap: Cap, +} + +impl VariableStrokeBuilder { + pub(super) fn add_subsegment( + subsegment: &SubSegment, + path: &[IntStrokeVertex], + output: &mut SegmentBuilder, + ) { + if subsegment.start == subsegment.end { + if subsegment.start_cap != Cap::Butt || subsegment.end_cap != Cap::Butt { + let vertex = &path[subsegment.start]; + output.add_circle( + &vertex.point, + vertex.radius(), + #[cfg(feature = "variable_stroke_debug")] + VariableStrokeDebugEdgeKind::CircleArc, + ); + } + return; + } + + let mut sections = (subsegment.start..subsegment.end) + .filter_map(|index| Section::try_new::(&path[index], &path[index + 1])); + let Some(mut previous) = sections.next() else { + return; + }; + + output.add_section(&previous); + output.add_start_cap(&previous, subsegment.start_cap); + + for section in sections { + output.add_section(§ion); + output.add_join(&previous, §ion); + previous = section; + } + + output.add_end_cap(&previous, subsegment.end_cap); + } + + pub(super) fn find_subsegments(path: &[IntStrokeVertex]) -> Vec { + if path.is_empty() { + return Vec::new(); + } + + let mut result = Vec::new(); + let mut start = 0; + let mut start_cap = Cap::Round; + let mut final_end_cap = Cap::Round; + + for (index, pair) in path.windows(2).enumerate() { + final_end_cap = Cap::Round; + + if let Some((end_cap, next_start_cap)) = Self::break_caps(&pair[0], &pair[1]) { + result.push(SubSegment { + start, + end: index, + start_cap, + end_cap, + }); + + start = index + 1; + start_cap = next_start_cap; + continue; + } + + if index > 0 && Self::circle_is_covered_by_section(&path[index - 1], &pair[0], &pair[1]) { + result.push(SubSegment { + start, + end: index, + start_cap, + end_cap: Cap::Round, + }); + + start = index; + start_cap = Cap::Butt; + final_end_cap = Cap::Butt; + } + } + + result.push(SubSegment { + start, + end: path.len() - 1, + start_cap, + end_cap: final_end_cap, + }); + result + } + + pub(super) fn break_caps(a: &IntStrokeVertex, b: &IntStrokeVertex) -> Option<(Cap, Cap)> { + let int_a = a.point; + let int_b = b.point; + let a_radius = a.radius(); + let b_radius = b.radius(); + let radius_delta = a_radius.to_wide() - b_radius.to_wide(); + let distance_sqr = (int_b - int_a).sqr_length(); + + if (radius_delta * radius_delta).to_uint() < distance_sqr { + return None; + } + + if a_radius >= b_radius { + Some((Cap::Round, Cap::Butt)) + } else { + Some((Cap::Butt, Cap::Round)) + } + } + + pub(super) fn circle_is_covered_by_section( + a: &IntStrokeVertex, + b: &IntStrokeVertex, + c: &IntStrokeVertex, + ) -> bool { + let a_radius = a.radius(); + let b_radius = b.radius(); + let c_radius = c.radius(); + if a_radius.max(b_radius) <= c_radius { + return false; + } + + let Some(section) = Section::try_new::(a, b) else { + return false; + }; + + let points = [section.a_left, section.b_left, section.b_right, section.a_right]; + let center = c.point; + let radius = c_radius.to_wide().to_uint(); + let first_edge = points[1] - points[0]; + let orientation = first_edge.cross_product(points[2] - points[1]); + if orientation == I::Wide::ZERO { + return false; + } + + for index in 0..points.len() { + let a = points[index]; + let b = points[(index + 1) % points.len()]; + let edge = b - a; + let side = edge.cross_product(center - a); + let interior_distance = if orientation > I::Wide::ZERO { side } else { -side }; + if interior_distance < I::Wide::ZERO { + return false; + } + + let length_sqr = edge.sqr_length(); + let mut length = length_sqr.isqrt(); + if length * length < length_sqr { + length += I::WideUInt::ONE; + } + if interior_distance.to_uint() < radius * length { + return false; + } + } + + true + } +} diff --git a/iOverlay/src/mesh/math.rs b/iOverlay/src/mesh/math.rs index 775a5b93..386f672f 100644 --- a/iOverlay/src/mesh/math.rs +++ b/iOverlay/src/mesh/math.rs @@ -1,4 +1,4 @@ -/// Arithmetic used to construct stroke offsets, joins, and caps. +/// Arithmetic used to construct outline offsets, strokes, and variable-width strokes. /// /// Both modes retain integer coordinates and use the same integer boolean engine. /// They can produce different rounded vertices and arc tessellations. @@ -11,7 +11,8 @@ pub enum MathMode { /// Fixed-point directions and integer trigonometry. This is the default. #[default] Integer, - /// f64 normalization and trigonometry. Directions are stored as UnitIntVector + /// f64 normalization, trigonometry, and variable-width tangent contacts. + /// Directions are stored as UnitIntVector /// without a norm check, then scaled with integer arithmetic. Their length /// may slightly exceed one. Cross-platform bitwise reproducibility is not /// promised. Arc rotation_precision is ignored. diff --git a/iOverlay/tests/outline_variable_math_tests.rs b/iOverlay/tests/outline_variable_math_tests.rs new file mode 100644 index 00000000..afe45689 --- /dev/null +++ b/iOverlay/tests/outline_variable_math_tests.rs @@ -0,0 +1,270 @@ +use i_float::int::{angle::Angle, point::IntPoint}; +use i_overlay::mesh::{ + float::{ + outline::offset::OutlineOffset, + style::{LineJoin, OutlineStyle}, + variable_stroke::{StrokeVertex, VariableStrokeStyle, offset::VariableStrokeOffset}, + }, + int::{ + arc::ArcOptions, + outline::offset::IntOutlineOffset, + style::{IntLineJoin, IntOutlineStyle}, + variable_stroke::{IntStrokeVertex, IntVariableStrokeStyle, offset::IntVariableStrokeOffset}, + }, + math::MathMode, +}; +use i_shape::{ + flat::{buffer::FlatContoursBuffer, float::FloatFlatContoursBuffer}, + int::area::Area, +}; + +#[test] +fn default_math_remains_integer() { + assert_eq!(IntOutlineStyle::new(100).math, MathMode::Integer); + assert_eq!(OutlineStyle::new(1.0).math, MathMode::Integer); + assert_eq!(IntVariableStrokeStyle::new().math, MathMode::Integer); + assert_eq!(VariableStrokeStyle::::new().math, MathMode::Integer); +} + +macro_rules! area_checks { + ($name:ident, $int:ty) => { + #[test] + fn $name() { + let square = [ + IntPoint::<$int>::new(-2000, -2000), + IntPoint::new(2000, -2000), + IntPoint::new(2000, 2000), + IntPoint::new(-2000, 2000), + ]; + for math in [MathMode::Integer, MathMode::Float] { + for offset in [-200, 0, 200] { + let style = IntOutlineStyle::new(offset) + .math(math) + .line_join(IntLineJoin::Miter(Angle::from_radians(0.1))); + square.validate_outline(&style).unwrap(); + let shapes = square.outline(&style).unwrap(); + let side = 4000.0 + 2.0 * offset as f64; + assert!((shapes.area() as f64 - side * side).abs() <= 2.0 * side); + let mut output = FlatContoursBuffer::default(); + square.outline_into(&style, &mut output).unwrap(); + assert_eq!( + output.points, + shapes + .iter() + .flatten() + .flatten() + .copied() + .collect::>() + ); + } + let path = [ + IntStrokeVertex::new(IntPoint::<$int>::new(0, 0), 400), + IntStrokeVertex::new(IntPoint::new(2400, 3200), 400), + ]; + let style = IntVariableStrokeStyle::new().math(math).arc(ArcOptions { + max_step: Angle::from_radians(0.03), + ..Default::default() + }); + path.validate_variable_stroke().unwrap(); + let shapes = path.variable_stroke(style).unwrap(); + let expected = 4000.0 * 400.0 + core::f64::consts::PI * 200.0 * 200.0; + assert_eq!(shapes.len(), 1); + assert!((shapes.area() as f64 - expected).abs() < 12000.0); + let mut output = FlatContoursBuffer::default(); + path.variable_stroke_into(style, &mut output).unwrap(); + assert_eq!( + output.points, + shapes + .iter() + .flatten() + .flatten() + .copied() + .collect::>() + ); + } + } + }; +} +area_checks!(areas_i16, i16); +area_checks!(areas_i32, i32); +area_checks!(areas_i64, i64); + +#[test] +fn float_math_preserves_local_geometry_at_large_origins() { + let outline = [ + IntPoint::new(0_i64, 0), + IntPoint::new(8000, 0), + IntPoint::new(9000, 6000), + IntPoint::new(1000, 5000), + ]; + let shift = 1_i64 << 60; + let translated = outline.map(|p| IntPoint::new(p.x + shift, p.y - shift)); + for join in [ + IntLineJoin::Bevel, + IntLineJoin::Miter(Angle::from_radians(0.1)), + IntLineJoin::Round(ArcOptions::default()), + ] { + let style = IntOutlineStyle::new(400).math(MathMode::Float).line_join(join); + let expected = outline.outline(&style).unwrap(); + let mut actual = translated.outline(&style).unwrap(); + for p in actual.iter_mut().flatten().flatten() { + p.x -= shift; + p.y += shift; + } + assert_eq!(actual, expected); + } + let path = [ + IntStrokeVertex::new(outline[0], 400), + IntStrokeVertex::new(outline[1], 1600), + IntStrokeVertex::new(outline[2], 800), + ]; + let translated = + path.map(|v| IntStrokeVertex::new(IntPoint::new(v.point.x + shift, v.point.y - shift), v.width)); + let style = IntVariableStrokeStyle::new().math(MathMode::Float); + let expected = path.variable_stroke(style).unwrap(); + let mut actual = translated.variable_stroke(style).unwrap(); + for p in actual.iter_mut().flatten().flatten() { + p.x -= shift; + p.y += shift; + } + assert_eq!(actual, expected); +} + +#[test] +fn float_adapters_forward_math_and_replace_flat_output() { + let outline = [[0.0, 0.0], [8.0, 0.0], [9.0, 6.0], [1.0, 5.0]]; + let path = [ + StrokeVertex::new([0.0, 0.0], 0.4), + StrokeVertex::new([8.0, 0.0], 1.6), + StrokeVertex::new([9.0, 6.0], 0.8), + ]; + for math in [MathMode::Integer, MathMode::Float] { + let style = OutlineStyle::new(0.4).math(math).line_join(LineJoin::Round(0.1)); + let shapes = outline.outline_fixed_scale(&style, 1000.0).unwrap(); + let mut output = FloatFlatContoursBuffer::default(); + outline + .outline_fixed_scale_into(&style, 1000.0, &mut output) + .unwrap(); + assert_eq!( + output.points, + shapes.iter().flatten().flatten().copied().collect::>() + ); + let int_outline = outline.map(|p| IntPoint::new((p[0] * 1000.0) as i32, (p[1] * 1000.0) as i32)); + let int_style = IntOutlineStyle::new(400) + .math(math) + .line_join(IntLineJoin::Round(ArcOptions { + max_step: Angle::from_radians(0.1), + ..Default::default() + })); + // The adapter may translate the origin, so compare translation-invariant areas. + let expected = int_outline.outline(&int_style).unwrap().area() as f64 / 1e6; + let area = |shapes: &Vec>>| { + shapes + .iter() + .flatten() + .map(|p| { + (0..p.len()) + .map(|i| p[i][0] * p[(i + 1) % p.len()][1] - p[i][1] * p[(i + 1) % p.len()][0]) + .sum::() + / 2.0 + }) + .sum::() + .abs() + }; + assert!((area(&shapes) - expected).abs() < 0.02); + let style = VariableStrokeStyle::new().math(math); + let shapes = path.variable_stroke_fixed_scale(style, 1000.0).unwrap(); + path.variable_stroke_fixed_scale_into(style, 1000.0, &mut output) + .unwrap(); + assert_eq!( + output.points, + shapes.iter().flatten().flatten().copied().collect::>() + ); + let int_path = path.map(|v| { + IntStrokeVertex::new( + IntPoint::new((v.point[0] * 1000.0) as i32, (v.point[1] * 1000.0) as i32), + (v.width * 1000.0) as i32, + ) + }); + let expected = int_path + .variable_stroke(IntVariableStrokeStyle::new().math(math)) + .unwrap() + .area() as f64 + / 1e6; + assert!((area(&shapes) - expected).abs() < 0.02); + let empty: [StrokeVertex<[f64; 2]>; 0] = []; + empty.variable_stroke_into(style, &mut output); + assert!(output.points.is_empty() && output.ranges.is_empty()); + } +} + +#[cfg(feature = "variable_stroke_debug")] +#[test] +fn debug_geometry_matches_normal_build_for_both_modes() { + let paths = vec![ + vec![], + vec![IntStrokeVertex::new(IntPoint::new(0, 0), 1000)], + vec![ + IntStrokeVertex::new(IntPoint::new(-2000, 0), 400), + IntStrokeVertex::new(IntPoint::new(0, 0), 6000), + IntStrokeVertex::new(IntPoint::new(100, 0), 100), + IntStrokeVertex::new(IntPoint::new(2000, 2000), 4000), + ], + ]; + for math in [MathMode::Integer, MathMode::Float] { + let style = IntVariableStrokeStyle::new().math(math); + let expected = paths.variable_stroke(style).unwrap(); + let debug = paths.variable_stroke_debug(style, Default::default()).unwrap(); + assert_eq!(debug.shapes, expected); + assert!(debug.edges.iter().any(|e| e.path_index == 1)); + assert!(debug.edges.iter().any(|e| e.path_index == 2)); + for (order, edge) in debug.edges.iter().enumerate() { + assert_eq!(edge.order, order); + assert_ne!(edge.a, edge.b); + } + } +} + +#[test] +fn outline_preserves_hole_roles_in_both_modes() { + let paths = vec![ + vec![ + IntPoint::new(-4000, -4000), + IntPoint::new(4000, -4000), + IntPoint::new(4000, 4000), + IntPoint::new(-4000, 4000), + ], + vec![ + IntPoint::new(-1000, -1000), + IntPoint::new(-1000, 1000), + IntPoint::new(1000, 1000), + IntPoint::new(1000, -1000), + ], + ]; + for math in [MathMode::Integer, MathMode::Float] { + for offset in [-200, 0, 200] { + let style = IntOutlineStyle::new(offset) + .math(math) + .line_join(IntLineJoin::Miter(Angle::from_radians(0.1))); + let shapes = paths.outline(&style).unwrap(); + assert_eq!(shapes.len(), 1); + assert_eq!(shapes[0].len(), 2); + let outer = 8000_i64 + 2 * offset as i64; + let inner = 2000_i64 - 2 * offset as i64; + assert_eq!(shapes.area(), outer * outer - inner * inner); + } + } +} + +#[test] +fn outline_bounds_use_the_selected_miter_limit() { + let path = [ + IntPoint::new(0_i16, 0), + IntPoint::new(1000, 0), + IntPoint::new(0, 1000), + ]; + // Integer math clamps to 5 degrees; Float uses the requested 1.8 degrees. + let style = IntOutlineStyle::new(300).line_join(IntLineJoin::Miter(Angle::from_radians(0.01))); + assert!(path.validate_outline(&style.math(MathMode::Integer)).is_ok()); + assert!(path.validate_outline(&style.math(MathMode::Float)).is_err()); +} diff --git a/iOverlay/tests/variable_stroke_stress.rs b/iOverlay/tests/variable_stroke_stress.rs index 1859bec9..bcce13d5 100644 --- a/iOverlay/tests/variable_stroke_stress.rs +++ b/iOverlay/tests/variable_stroke_stress.rs @@ -1,5 +1,6 @@ use i_overlay::mesh::float::variable_stroke::offset::VariableStrokeOffset; use i_overlay::mesh::float::variable_stroke::{StrokeVertex, VariableStrokeStyle}; +use i_overlay::mesh::math::MathMode; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::time::{Duration, Instant}; @@ -41,18 +42,21 @@ fn run_variable_stroke_stress_case(seed: u64, iteration: usize) { let style = VariableStrokeStyle::new().round_angle(round_angle); let result = catch_unwind(AssertUnwindSafe(|| { - let shapes = path.variable_stroke(style); - assert_valid_shapes(&shapes, seed); + for math in [MathMode::Integer, MathMode::Float] { + let style = style.math(math); + let shapes = path.variable_stroke(style); + assert_valid_shapes(&shapes, seed); + + if seed & 1 == 0 { + let shapes = path.variable_stroke_as::(style); + assert_valid_shapes(&shapes, seed); + } - if seed & 1 == 0 { - let shapes = path.variable_stroke_as::(style); + let mut reversed = path.clone(); + reversed.reverse(); + let shapes = reversed.variable_stroke(style); assert_valid_shapes(&shapes, seed); } - - let mut reversed = path.clone(); - reversed.reverse(); - let shapes = reversed.variable_stroke(style); - assert_valid_shapes(&shapes, seed); })); if let Err(payload) = result { From c46cbd2e1322b52f5189b2e3f056032df688e4cf Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Fri, 18 Sep 2026 21:12:19 +0300 Subject: [PATCH 33/44] resource api --- iOverlay/src/core/mod.rs | 1 + iOverlay/src/core/overlay.rs | 107 ++++++-- iOverlay/src/core/relate.rs | 131 ++++++++++ iOverlay/src/core/simplify.rs | 54 ++-- iOverlay/src/core/single.rs | 40 +++ iOverlay/src/mesh/float/style.rs | 14 +- .../src/mesh/float/variable_stroke/offset.rs | 4 +- iOverlay/src/string/clip.rs | 90 ++----- iOverlay/src/string/overlay.rs | 86 ++++++- iOverlay/src/string/slice.rs | 118 ++------- iOverlay/tests/angle_style_tests.rs | 66 +++++ iOverlay/tests/int_resource_tests.rs | 234 ++++++++++++++++++ iOverlay/tests/miter_regression_tests.rs | 9 +- iOverlay/tests/outline_variable_math_tests.rs | 12 +- iOverlay/tests/stroke_bench.rs | 2 +- iOverlay/tests/stroke_math_tests.rs | 2 +- 16 files changed, 718 insertions(+), 252 deletions(-) create mode 100644 iOverlay/src/core/single.rs create mode 100644 iOverlay/tests/angle_style_tests.rs create mode 100644 iOverlay/tests/int_resource_tests.rs diff --git a/iOverlay/src/core/mod.rs b/iOverlay/src/core/mod.rs index 64af2b8d..2caaeac4 100644 --- a/iOverlay/src/core/mod.rs +++ b/iOverlay/src/core/mod.rs @@ -14,4 +14,5 @@ pub mod point_location; pub mod predicate; pub mod relate; pub mod simplify; +pub mod single; pub mod solver; diff --git a/iOverlay/src/core/overlay.rs b/iOverlay/src/core/overlay.rs index d9596309..072a8a9c 100644 --- a/iOverlay/src/core/overlay.rs +++ b/iOverlay/src/core/overlay.rs @@ -18,8 +18,8 @@ use crate::vector::edge::{DataVectorEdge, VectorShape}; use alloc::vec::Vec; use i_float::int::number::uint::UIntNumber; use i_float::int::point::IntPoint; -use i_shape::int::count::PointsCount; use i_shape::int::shape::{IntContour, IntShape, IntShapes}; +use i_shape::source::int::resource::IntShapeResource; use super::graph::{OverlayGraph, OverlayNode}; @@ -110,14 +110,86 @@ where } } + /// Creates an overlay from subject and clip resources, which may use different storage types. + /// Paths are interpreted as closed contours; fill rules determine their interiors. + /// Accepts contours, shapes, collections of shapes, borrowed paths, and flat buffers + /// through [`IntShapeResource`], without copying them into intermediate containers. + pub fn from_subj_and_clip(subj: &R0, clip: &R1) -> Self + where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + { + Self::from_subj_and_clip_custom(subj, clip, Default::default(), Default::default()) + } + + /// Creates an overlay from resources with custom output options and solver. + pub fn from_subj_and_clip_custom( + subj: &R0, + clip: &R1, + options: IntOverlayOptions, + solver: Solver, + ) -> Self + where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + { + let capacity = subj + .iter_paths() + .chain(clip.iter_paths()) + .map(|path| path.len()) + .sum(); + let mut overlay = Self::new_custom(capacity, options, solver); + overlay.add_source(subj, ShapeType::Subject); + overlay.add_source(clip, ShapeType::Clip); + overlay + } + + /// Creates an overlay from a subject resource, interpreting each path as a closed contour. + pub fn from_subj + ?Sized>(subj: &R) -> Self { + Self::from_subj_custom(subj, Default::default(), Default::default()) + } + + /// Creates a subject-only overlay with custom output options and solver. + pub fn from_subj_custom + ?Sized>( + subj: &R, + options: IntOverlayOptions, + solver: Solver, + ) -> Self { + let capacity = subj.iter_paths().map(|path| path.len()).sum(); + let mut overlay = Self::new_custom(capacity, options, solver); + overlay.add_source(subj, ShapeType::Subject); + overlay + } + + /// Adds all resource paths as closed contours without intermediate storage. + pub fn add_source + ?Sized>(&mut self, resource: &R, shape_type: ShapeType) { + for contour in resource.iter_paths() { + self.add_contour(contour, shape_type); + } + } + + /// Replaces the geometry while retaining allocated storage, options, and solver. + pub fn reinit_with_subj_and_clip(&mut self, subj: &R0, clip: &R1) + where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + { + self.clear(); + self.add_source(subj, ShapeType::Subject); + self.add_source(clip, ShapeType::Clip); + } + + /// Replaces the geometry with a subject resource, retaining storage and configuration. + pub fn reinit_with_subj + ?Sized>(&mut self, subj: &R) { + self.clear(); + self.add_source(subj, ShapeType::Subject); + } + /// Creates a new `Overlay` instance and initializes it with subject and clip contours. /// - `subj`: An array of contours that together define the subject. /// - `clip`: An array of contours that together define the clip. pub fn with_contour(subj: &[IntPoint], clip: &[IntPoint]) -> Self { - let mut overlay = Self::new(subj.len() + clip.len()); - overlay.add_contour(subj, ShapeType::Subject); - overlay.add_contour(clip, ShapeType::Clip); - overlay + Self::from_subj_and_clip(subj, clip) } /// Creates a new `Overlay` instance and initializes it with subject and clip contours. @@ -131,20 +203,14 @@ where options: IntOverlayOptions, solver: Solver, ) -> Self { - let mut overlay = Self::new_custom(subj.len() + clip.len(), options, solver); - overlay.add_contour(subj, ShapeType::Subject); - overlay.add_contour(clip, ShapeType::Clip); - overlay + Self::from_subj_and_clip_custom(subj, clip, options, solver) } /// Creates a new `Overlay` instance and initializes it with subject and clip contours. /// - `subj`: An array of contours that together define the subject shape. /// - `clip`: An array of contours that together define the clip shape. pub fn with_contours(subj: &[IntContour], clip: &[IntContour]) -> Self { - let mut overlay = Self::new(subj.points_count() + clip.points_count()); - overlay.add_contours(subj, ShapeType::Subject); - overlay.add_contours(clip, ShapeType::Clip); - overlay + Self::from_subj_and_clip(subj, clip) } /// Creates a new `Overlay` instance and initializes it with subject and clip contours. @@ -158,20 +224,14 @@ where options: IntOverlayOptions, solver: Solver, ) -> Self { - let mut overlay = Self::new_custom(subj.points_count() + clip.points_count(), options, solver); - overlay.add_contours(subj, ShapeType::Subject); - overlay.add_contours(clip, ShapeType::Clip); - overlay + Self::from_subj_and_clip_custom(subj, clip, options, solver) } /// Creates a new `Overlay` instance and initializes it with subject and clip shapes. /// - `subj`: An array of shapes to be used as the subject in the overlay operation. /// - `clip`: An array of shapes to be used as the clip in the overlay operation. pub fn with_shapes(subj: &[IntShape], clip: &[IntShape]) -> Self { - let mut overlay = Self::new(subj.points_count() + clip.points_count()); - overlay.add_shapes(subj, ShapeType::Subject); - overlay.add_shapes(clip, ShapeType::Clip); - overlay + Self::from_subj_and_clip(subj, clip) } /// Creates a new `Overlay` instance and initializes it with subject and clip shapes. @@ -185,10 +245,7 @@ where options: IntOverlayOptions, solver: Solver, ) -> Self { - let mut overlay = Self::new_custom(subj.points_count() + clip.points_count(), options, solver); - overlay.add_shapes(subj, ShapeType::Subject); - overlay.add_shapes(clip, ShapeType::Clip); - overlay + Self::from_subj_and_clip_custom(subj, clip, options, solver) } /// Adds a path to the overlay using an iterator, allowing for more flexible path input. diff --git a/iOverlay/src/core/relate.rs b/iOverlay/src/core/relate.rs index 3ba65ac2..2fe95201 100644 --- a/iOverlay/src/core/relate.rs +++ b/iOverlay/src/core/relate.rs @@ -13,6 +13,7 @@ use crate::split::solver::SplitSolver; use alloc::vec::Vec; use i_float::int::point::IntPoint; use i_shape::int::shape::{IntContour, IntShape}; +use i_shape::source::int::resource::IntShapeResource; /// Overlay structure optimized for spatial predicate evaluation. /// @@ -62,6 +63,57 @@ where } } + /// Creates a predicate overlay from closed subject and clip paths using even-odd fill. + pub fn from_subj_and_clip(subj: &R0, clip: &R1) -> Self + where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + { + Self::from_subj_and_clip_custom(subj, clip, FillRule::EvenOdd, Default::default()) + } + + /// Creates a predicate overlay with a custom fill rule and solver. + pub fn from_subj_and_clip_custom( + subj: &R0, + clip: &R1, + fill_rule: FillRule, + solver: Solver, + ) -> Self + where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + { + let capacity = subj + .iter_paths() + .chain(clip.iter_paths()) + .map(|path| path.len()) + .sum(); + let mut overlay = Self::new(capacity); + overlay.fill_rule = fill_rule; + overlay.solver = solver; + overlay.add_source(subj, ShapeType::Subject); + overlay.add_source(clip, ShapeType::Clip); + overlay + } + + /// Adds all resource paths as closed subject or clip contours. + pub fn add_source + ?Sized>(&mut self, resource: &R, shape_type: ShapeType) { + for contour in resource.iter_paths() { + self.add_contour(contour, shape_type); + } + } + + /// Replaces geometry while retaining allocated storage, fill rule, and solver. + pub fn reinit_with_subj_and_clip(&mut self, subj: &R0, clip: &R1) + where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + { + self.clear(); + self.add_source(subj, ShapeType::Subject); + self.add_source(clip, ShapeType::Clip); + } + fn evaluate>(&mut self, handler: H) -> T { if self.segments.is_empty() { return T::default(); @@ -174,6 +226,85 @@ where } } +/// One-shot spatial predicates on closed integer shape resources using even-odd fill. +/// Use [`PredicateOverlay::from_subj_and_clip_custom`] for another fill rule or solver. +/// +/// ``` +/// use i_overlay::core::relate::IntRelate; +/// use i_overlay::i_float::int::point::IntPoint; +/// let square = [IntPoint::new(0, 0), IntPoint::new(10, 0), +/// IntPoint::new(10, 10), IntPoint::new(0, 10)]; +/// assert!(square.intersects(&square[..])); +/// ``` +pub trait IntRelate +where + R: IntShapeResource + ?Sized, + I: OverlayInt, +{ + /// Returns true if the resources share any point. + fn intersects(&self, other: &R) -> bool; + + /// Returns true if the interiors overlap. + fn interiors_intersect(&self, other: &R) -> bool; + + /// Returns true if boundaries intersect but interiors do not. + fn touches(&self, other: &R) -> bool; + + /// Returns true if the resources intersect by point coincidence only. + fn point_intersects(&self, other: &R) -> bool; + + /// Returns true if this resource is completely within the other. + fn within(&self, other: &R) -> bool; + + /// Returns true if the resources have no shared points. + fn disjoint(&self, other: &R) -> bool; + + /// Returns true if this resource completely covers the other. + fn covers(&self, other: &R) -> bool; +} + +impl IntRelate for R0 +where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + I: OverlayInt, +{ + #[inline] + fn intersects(&self, other: &R1) -> bool { + PredicateOverlay::from_subj_and_clip(self, other).intersects() + } + + #[inline] + fn interiors_intersect(&self, other: &R1) -> bool { + PredicateOverlay::from_subj_and_clip(self, other).interiors_intersect() + } + + #[inline] + fn touches(&self, other: &R1) -> bool { + PredicateOverlay::from_subj_and_clip(self, other).touches() + } + + #[inline] + fn point_intersects(&self, other: &R1) -> bool { + PredicateOverlay::from_subj_and_clip(self, other).point_intersects() + } + + #[inline] + fn within(&self, other: &R1) -> bool { + PredicateOverlay::from_subj_and_clip(self, other).within() + } + + #[inline] + fn disjoint(&self, other: &R1) -> bool { + !PredicateOverlay::from_subj_and_clip(self, other).intersects() + } + + #[inline] + fn covers(&self, other: &R1) -> bool { + PredicateOverlay::from_subj_and_clip(other, self).within() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/iOverlay/src/core/simplify.rs b/iOverlay/src/core/simplify.rs index 12ee8b1b..3cd566e1 100644 --- a/iOverlay/src/core/simplify.rs +++ b/iOverlay/src/core/simplify.rs @@ -13,9 +13,10 @@ use alloc::vec; use i_shape::flat::buffer::FlatContoursBuffer; use crate::segm::build::BuildSegments; -use i_shape::int::count::PointsCount; +use i_float::int::number::uint::UIntNumber; use i_shape::int::path::ContourExtension; use i_shape::int::shape::{IntContour, IntShape, IntShapes}; +use i_shape::source::int::resource::IntShapeResource; /// Trait `Simplify` provides a method to simplify geometric shapes by reducing the number of points in contours or shapes /// while preserving overall shape and topology. The method applies a minimum area threshold and a build rule to @@ -35,39 +36,15 @@ pub trait Simplify { fn simplify(&self, fill_rule: FillRule, options: IntOverlayOptions) -> IntShapes; } -impl Simplify for [IntPoint] +impl Simplify for R where I: OverlayInt, + R: IntShapeResource + ?Sized, { #[inline] fn simplify(&self, fill_rule: FillRule, options: IntOverlayOptions) -> IntShapes { - match Overlay::new_custom(self.len(), options, Default::default()).simplify_contour(self, fill_rule) { - Some(shapes) => shapes, - None => vec![vec![self.to_vec()]], - } - } -} - -impl Simplify for [IntContour] -where - I: OverlayInt, -{ - #[inline] - fn simplify(&self, fill_rule: FillRule, options: IntOverlayOptions) -> IntShapes { - match Overlay::new_custom(self.len(), options, Default::default()).simplify_shape(self, fill_rule) { - Some(shapes) => shapes, - None => vec![self.to_vec()], - } - } -} - -impl Simplify for [IntShape] -where - I: OverlayInt, -{ - #[inline] - fn simplify(&self, fill_rule: FillRule, options: IntOverlayOptions) -> IntShapes { - Overlay::new_custom(self.points_count(), options, Default::default()).simplify_shapes(self, fill_rule) + let capacity = self.iter_paths().map(|path| path.len()).sum(); + Overlay::new_custom(capacity, options, Default::default()).simplify_source(self, fill_rule) } } @@ -81,6 +58,25 @@ impl Overlay where I: OverlayInt, { + /// Simplifies a resource, reusing this overlay's storage and configuration. + /// A single contour uses the fast path when no output area filter is requested. + pub fn simplify_source + ?Sized>( + &mut self, + resource: &R, + fill_rule: FillRule, + ) -> IntShapes { + let mut paths = resource.iter_paths(); + if let Some(contour) = paths.next() { + if paths.next().is_none() && self.options.min_output_area == I::WideUInt::ZERO { + return self + .simplify_contour(contour, fill_rule) + .unwrap_or_else(|| vec![vec![contour.to_vec()]]); + } + } + self.reinit_with_subj(resource); + self.overlay(OverlayRule::Subject, fill_rule) + } + /// Fast-path simplification for a single contour. /// /// Skips full overlay if the contour is already simple (no splits, no loops, no collinear issues). diff --git a/iOverlay/src/core/single.rs b/iOverlay/src/core/single.rs new file mode 100644 index 00000000..1c1857d8 --- /dev/null +++ b/iOverlay/src/core/single.rs @@ -0,0 +1,40 @@ +//! One-shot Boolean operations on integer shape resources. +use crate::core::fill_rule::FillRule; +use crate::core::integer::OverlayInt; +use crate::core::overlay::Overlay; +use crate::core::overlay_rule::OverlayRule; +use i_shape::int::shape::IntShapes; +use i_shape::source::int::resource::IntShapeResource; + +/// Boolean operations between resources with independently chosen storage types. +/// Each input path is interpreted as a closed contour. +/// +/// ``` +/// use i_overlay::core::{fill_rule::FillRule, overlay_rule::OverlayRule, single::SingleIntOverlay}; +/// use i_overlay::i_float::int::point::IntPoint; +/// +/// let square = [IntPoint::new(0, 0), IntPoint::new(10, 0), +/// IntPoint::new(10, 10), IntPoint::new(0, 10)]; +/// let result = square.overlay(&square[..], OverlayRule::Intersect, FillRule::NonZero); +/// assert_eq!(result.len(), 1); +/// ``` +pub trait SingleIntOverlay +where + R: IntShapeResource + ?Sized, + I: OverlayInt, +{ + /// Applies a Boolean operation using the supplied fill rule for both resources. + fn overlay(&self, source: &R, overlay_rule: OverlayRule, fill_rule: FillRule) -> IntShapes; +} + +impl SingleIntOverlay for R0 +where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + I: OverlayInt, +{ + #[inline] + fn overlay(&self, source: &R1, overlay_rule: OverlayRule, fill_rule: FillRule) -> IntShapes { + Overlay::from_subj_and_clip(self, source).overlay(overlay_rule, fill_rule) + } +} diff --git a/iOverlay/src/mesh/float/style.rs b/iOverlay/src/mesh/float/style.rs index 3845c3b2..3b5e0c13 100644 --- a/iOverlay/src/mesh/float/style.rs +++ b/iOverlay/src/mesh/float/style.rs @@ -11,6 +11,14 @@ use i_float::int::angle::Angle; use i_float::int::number::int::IntNumber; use i_float::int::point::IntPoint; +pub(super) fn angle_from_radians(radians: T) -> Angle { + Angle::from_radians(radians).unwrap_or_else(|| { + // Normalization clamps infinities, but preserves NaN. Use the same + // minimum as floating-point joins and caps instead of panicking. + Angle::from_radians(T::from_float(0.01 * PI)).expect("minimum style angle is finite") + }) +} + /// The endpoint style of a line. #[derive(Debug, Clone)] pub enum LineCap { @@ -84,9 +92,9 @@ impl From<&LineJoin> for IntLineJoin { fn from(join: &LineJoin) -> Self { match join.clone().normalize() { LineJoin::Bevel => IntLineJoin::Bevel, - LineJoin::Miter(a) => IntLineJoin::Miter(Angle::from_radians(a)), + LineJoin::Miter(a) => IntLineJoin::Miter(angle_from_radians(a)), LineJoin::Round(a) => IntLineJoin::Round(ArcOptions { - max_step: Angle::from_radians(a), + max_step: angle_from_radians(a), ..ArcOptions::default() }), } @@ -165,7 +173,7 @@ impl StrokeStyle

{ LineCap::Butt => IntLineCap::Butt, LineCap::Square => IntLineCap::Square, LineCap::Round(a) => IntLineCap::Round(ArcOptions { - max_step: Angle::from_radians(a), + max_step: angle_from_radians(a), ..ArcOptions::default() }), LineCap::Custom(points) => IntLineCap::Custom( diff --git a/iOverlay/src/mesh/float/variable_stroke/offset.rs b/iOverlay/src/mesh/float/variable_stroke/offset.rs index b0c1adff..75224611 100644 --- a/iOverlay/src/mesh/float/variable_stroke/offset.rs +++ b/iOverlay/src/mesh/float/variable_stroke/offset.rs @@ -2,6 +2,7 @@ use crate::core::integer::OverlayInt; use crate::core::{fill_rule::FillRule, overlay_rule::OverlayRule}; use crate::float::overlay::OverlayOptions; use crate::float::scale::FixedScaleOverlayError; +use crate::mesh::float::style::angle_from_radians; use crate::mesh::float::variable_stroke::resource::VariableStrokeSource; use crate::mesh::float::variable_stroke::style::VariableStrokeStyle; use crate::mesh::int::variable_stroke::build::build_variable_overlay_iter; @@ -11,7 +12,6 @@ use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; use i_float::float::number::FloatNumber; use i_float::float::rect::FloatRect; -use i_float::int::angle::Angle; use i_float::int::number::int::IntNumber; use i_float::int::number::uint::UIntNumber; use i_float::int::number::wide_int::WideIntNumber; @@ -297,7 +297,7 @@ where IntVariableStrokeStyle { math: self.style.math, arc: crate::mesh::int::arc::ArcOptions { - max_step: Angle::from_radians(self.style.round_angle), + max_step: angle_from_radians(self.style.round_angle), ..Default::default() }, } diff --git a/iOverlay/src/string/clip.rs b/iOverlay/src/string/clip.rs index 90d25be2..abefb9b9 100644 --- a/iOverlay/src/string/clip.rs +++ b/iOverlay/src/string/clip.rs @@ -9,9 +9,8 @@ use crate::string::line::IntLine; use crate::string::overlay::StringOverlay; use alloc::vec::Vec; use i_float::int::number::int::IntNumber; -use i_float::int::point::IntPoint; use i_shape::int::path::IntPath; -use i_shape::int::shape::{IntShape, IntShapes}; +use i_shape::source::int::resource::IntShapeResource; #[derive(Debug, Clone, Copy)] pub struct ClipRule { @@ -157,102 +156,51 @@ pub trait IntClip { /// # Returns /// A vector of `IntPath` instances containing the clipped portions of the input paths. fn clip_paths(&self, paths: &[IntPath], fill_rule: FillRule, clip_rule: ClipRule) -> Vec>; -} - -impl IntClip for IntShapes -where - I: OverlayInt, -{ - #[inline] - fn clip_line(&self, line: IntLine, fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shapes(self); - overlay.add_string_line(line); - overlay.clip_string_lines(fill_rule, clip_rule) - } - - #[inline] - fn clip_lines(&self, lines: &[IntLine], fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shapes(self); - overlay.add_string_lines(lines); - overlay.clip_string_lines(fill_rule, clip_rule) - } - - #[inline] - fn clip_path(&self, path: &IntPath, fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shapes(self); - overlay.add_string_path(path); - overlay.clip_string_lines(fill_rule, clip_rule) - } - - #[inline] - fn clip_paths(&self, paths: &[IntPath], fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shapes(self); - overlay.add_string_paths(paths); - overlay.clip_string_lines(fill_rule, clip_rule) - } -} - -impl IntClip for IntShape -where - I: OverlayInt, -{ - #[inline] - fn clip_line(&self, line: IntLine, fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shape(self); - overlay.add_string_line(line); - overlay.clip_string_lines(fill_rule, clip_rule) - } - #[inline] - fn clip_lines(&self, lines: &[IntLine], fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shape(self); - overlay.add_string_lines(lines); - overlay.clip_string_lines(fill_rule, clip_rule) - } - - #[inline] - fn clip_path(&self, path: &IntPath, fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shape(self); - overlay.add_string_path(path); - overlay.clip_string_lines(fill_rule, clip_rule) - } - - #[inline] - fn clip_paths(&self, paths: &[IntPath], fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shape(self); - overlay.add_string_paths(paths); - overlay.clip_string_lines(fill_rule, clip_rule) + /// Clips every resource path as an open string against this polygon resource. + fn clip_source + ?Sized>( + &self, + source: &R, + fill_rule: FillRule, + clip_rule: ClipRule, + ) -> Vec> + where + Self: IntShapeResource, + I: OverlayInt, + { + StringOverlay::from_shape_and_string(self, source).clip_string_lines(fill_rule, clip_rule) } } -impl IntClip for [IntPoint] +impl IntClip for R where I: OverlayInt, + R: IntShapeResource + ?Sized, { #[inline] fn clip_line(&self, line: IntLine, fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shape_contour(self); + let mut overlay = StringOverlay::from_shape(self); overlay.add_string_line(line); overlay.clip_string_lines(fill_rule, clip_rule) } #[inline] fn clip_lines(&self, lines: &[IntLine], fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shape_contour(self); + let mut overlay = StringOverlay::from_shape(self); overlay.add_string_lines(lines); overlay.clip_string_lines(fill_rule, clip_rule) } #[inline] fn clip_path(&self, path: &IntPath, fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shape_contour(self); + let mut overlay = StringOverlay::from_shape(self); overlay.add_string_path(path); overlay.clip_string_lines(fill_rule, clip_rule) } #[inline] fn clip_paths(&self, paths: &[IntPath], fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { - let mut overlay = StringOverlay::with_shape_contour(self); + let mut overlay = StringOverlay::from_shape(self); overlay.add_string_paths(paths); overlay.clip_string_lines(fill_rule, clip_rule) } diff --git a/iOverlay/src/string/overlay.rs b/iOverlay/src/string/overlay.rs index 1da56493..e5699cf4 100644 --- a/iOverlay/src/string/overlay.rs +++ b/iOverlay/src/string/overlay.rs @@ -16,9 +16,9 @@ use crate::string::line::IntLine; use alloc::vec::Vec; use core::cmp::Ordering; use i_float::int::point::IntPoint; -use i_shape::int::count::PointsCount; use i_shape::int::path::IntPath; use i_shape::int::shape::{IntContour, IntShape}; +use i_shape::source::int::resource::IntShapeResource; /// Integer polygon and string overlay builder. /// @@ -61,40 +61,100 @@ where } } + /// Creates an overlay from polygon paths, interpreted as closed contours. + pub fn from_shape + ?Sized>(shape: &R) -> Self { + Self::from_shape_custom(shape, Default::default()) + } + + /// Creates a polygon overlay with custom output options. + pub fn from_shape_custom + ?Sized>( + shape: &R, + options: IntOverlayOptions, + ) -> Self { + let capacity = shape.iter_paths().map(|path| path.len()).sum(); + let mut overlay = Self::with_options(capacity, options); + overlay.add_shape_source(shape); + overlay + } + + /// Creates an overlay from closed polygon contours and open string paths. + /// String paths are not implicitly closed, regardless of their storage type. + pub fn from_shape_and_string(shape: &R0, string: &R1) -> Self + where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + { + Self::from_shape_and_string_custom(shape, string, Default::default()) + } + + /// Creates an overlay from closed polygon contours and open strings with custom options. + pub fn from_shape_and_string_custom( + shape: &R0, + string: &R1, + options: IntOverlayOptions, + ) -> Self + where + R0: IntShapeResource + ?Sized, + R1: IntShapeResource + ?Sized, + { + let capacity = shape + .iter_paths() + .chain(string.iter_paths()) + .map(|path| path.len()) + .sum(); + let mut overlay = Self::with_options(capacity, options); + overlay.add_shape_source(shape); + overlay.add_string_source(string); + overlay + } + + /// Adds resource paths as closed polygon contours. + pub fn add_shape_source + ?Sized>(&mut self, resource: &R) { + for contour in resource.iter_paths() { + self.add_shape_contour(contour); + } + } + + /// Adds resource paths as open strings, without a closing edge. + pub fn add_string_source + ?Sized>(&mut self, resource: &R) { + for path in resource.iter_paths() { + self.add_string_path(path); + } + } + + /// Adds resource paths as closed strings, including the last-to-first edge. + pub fn add_string_contour_source + ?Sized>(&mut self, resource: &R) { + for contour in resource.iter_paths() { + self.add_string_contour(contour); + } + } + /// Creates a new `StringOverlay` instance and initializes it with a single shape contour. /// - `contour`: An array of points that form a closed path. #[inline] pub fn with_shape_contour(contour: &[IntPoint]) -> Self { - let mut overlay = Self::new(contour.len()); - overlay.add_shape_contour(contour); - overlay + Self::from_shape(contour) } /// Creates a new `StringOverlay` instance and initializes it with multiple shape contours. /// - `contours`: An array of `IntContour` instances to be added to the overlay. #[inline] pub fn with_shape_contours(contours: &[IntContour]) -> Self { - let mut overlay = Self::new(contours.points_count()); - overlay.add_shape_contours(contours); - overlay + Self::from_shape(contours) } /// Creates a new `StringOverlay` instance and initializes it with s shape. /// - `shape`: An `IntShape` instances to be added to the overlay. #[inline] pub fn with_shape(shape: &[IntContour]) -> Self { - let mut overlay = Self::new(shape.points_count()); - overlay.add_shape_contours(shape); - overlay + Self::from_shape(shape) } /// Creates a new `StringOverlay` instance and initializes it with subject and clip shapes. /// - `shapes`: An array of `IntShape` instances to be added to the overlay. #[inline] pub fn with_shapes(shapes: &[IntShape]) -> Self { - let mut overlay = Self::new(shapes.points_count()); - overlay.add_shapes(shapes); - overlay + Self::from_shape(shapes) } /// Adds a path to the overlay using an iterator, allowing for more flexible path input. diff --git a/iOverlay/src/string/slice.rs b/iOverlay/src/string/slice.rs index a2f1559d..a99cd1d4 100644 --- a/iOverlay/src/string/slice.rs +++ b/iOverlay/src/string/slice.rs @@ -4,114 +4,41 @@ use crate::string::line::IntLine; use crate::string::overlay::StringOverlay; use crate::string::rule::StringRule; use i_float::int::number::int::IntNumber; -use i_float::int::point::IntPoint; use i_shape::int::path::IntPath; -use i_shape::int::shape::{IntShape, IntShapes}; +use i_shape::int::shape::IntShapes; +use i_shape::source::int::resource::IntShapeResource; pub trait IntSlice { fn slice_by_line(&self, line: IntLine, fill_rule: FillRule) -> IntShapes; fn slice_by_lines(&self, lines: &[IntLine], fill_rule: FillRule) -> IntShapes; fn slice_by_path(&self, path: &IntPath, fill_rule: FillRule) -> IntShapes; fn slice_by_paths(&self, paths: &[IntPath], fill_rule: FillRule) -> IntShapes; -} - -impl IntSlice for IntShapes -where - I: OverlayInt, -{ - #[inline] - fn slice_by_line(&self, line: IntLine, fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shapes(self); - overlay.add_string_line(line); - overlay - .build_graph_view(fill_rule) - .map(|graph| graph.extract_shapes(StringRule::Slice)) - .unwrap_or_default() - } - - #[inline] - fn slice_by_lines(&self, lines: &[IntLine], fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shapes(self); - overlay.add_string_lines(lines); - overlay - .build_graph_view(fill_rule) - .map(|graph| graph.extract_shapes(StringRule::Slice)) - .unwrap_or_default() - } - - #[inline] - fn slice_by_path(&self, path: &IntPath, fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shapes(self); - overlay.add_string_path(path); - overlay - .build_graph_view(fill_rule) - .map(|graph| graph.extract_shapes(StringRule::Slice)) - .unwrap_or_default() - } - - #[inline] - fn slice_by_paths(&self, paths: &[IntPath], fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shapes(self); - overlay.add_string_paths(paths); - overlay - .build_graph_view(fill_rule) - .map(|graph| graph.extract_shapes(StringRule::Slice)) - .unwrap_or_default() - } -} - -impl IntSlice for IntShape -where - I: OverlayInt, -{ - #[inline] - fn slice_by_line(&self, line: IntLine, fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shape(self); - overlay.add_string_line(line); - overlay - .build_graph_view(fill_rule) - .map(|graph| graph.extract_shapes(StringRule::Slice)) - .unwrap_or_default() - } - #[inline] - fn slice_by_lines(&self, lines: &[IntLine], fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shape(self); - overlay.add_string_lines(lines); - overlay - .build_graph_view(fill_rule) - .map(|graph| graph.extract_shapes(StringRule::Slice)) - .unwrap_or_default() - } - - #[inline] - fn slice_by_path(&self, path: &IntPath, fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shape(self); - overlay.add_string_path(path); - overlay - .build_graph_view(fill_rule) - .map(|graph| graph.extract_shapes(StringRule::Slice)) - .unwrap_or_default() - } - - #[inline] - fn slice_by_paths(&self, paths: &[IntPath], fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shape(self); - overlay.add_string_paths(paths); - overlay + /// Slices this polygon resource by open paths from another resource. + fn slice_by_source + ?Sized>( + &self, + source: &R, + fill_rule: FillRule, + ) -> IntShapes + where + Self: IntShapeResource, + I: OverlayInt, + { + StringOverlay::from_shape_and_string(self, source) .build_graph_view(fill_rule) .map(|graph| graph.extract_shapes(StringRule::Slice)) .unwrap_or_default() } } -impl IntSlice for [IntPoint] +impl IntSlice for R where I: OverlayInt, + R: IntShapeResource + ?Sized, { #[inline] fn slice_by_line(&self, line: IntLine, fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shape_contour(self); + let mut overlay = StringOverlay::from_shape(self); overlay.add_string_line(line); overlay .build_graph_view(fill_rule) @@ -121,7 +48,7 @@ where #[inline] fn slice_by_lines(&self, lines: &[IntLine], fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shape_contour(self); + let mut overlay = StringOverlay::from_shape(self); overlay.add_string_lines(lines); overlay .build_graph_view(fill_rule) @@ -131,7 +58,7 @@ where #[inline] fn slice_by_path(&self, path: &IntPath, fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shape_contour(self); + let mut overlay = StringOverlay::from_shape(self); overlay.add_string_path(path); overlay .build_graph_view(fill_rule) @@ -141,7 +68,7 @@ where #[inline] fn slice_by_paths(&self, paths: &[IntPath], fill_rule: FillRule) -> IntShapes { - let mut overlay = StringOverlay::with_shape_contour(self); + let mut overlay = StringOverlay::from_shape(self); overlay.add_string_paths(paths); overlay .build_graph_view(fill_rule) @@ -161,11 +88,8 @@ mod tests { #[test] fn test_empty_input() { - #[rustfmt::skip] - let shapes = [].slice_by_line( - [IntPoint::new(0, 0), IntPoint::new(0, 0)], - FillRule::NonZero, - ); + let empty: &[IntPoint] = &[]; + let shapes = empty.slice_by_line([IntPoint::new(0, 0), IntPoint::new(0, 0)], FillRule::NonZero); assert_eq!(shapes.len(), 0); } diff --git a/iOverlay/tests/angle_style_tests.rs b/iOverlay/tests/angle_style_tests.rs new file mode 100644 index 00000000..f377eef1 --- /dev/null +++ b/iOverlay/tests/angle_style_tests.rs @@ -0,0 +1,66 @@ +use i_overlay::mesh::{ + float::{ + outline::offset::OutlineOffset, + stroke::offset::StrokeOffset, + style::{LineCap, LineJoin, OutlineStyle, StrokeStyle}, + variable_stroke::{StrokeVertex, VariableStrokeStyle, offset::VariableStrokeOffset}, + }, + math::MathMode, +}; + +macro_rules! non_finite_style_angles { + ($name:ident, $scalar:ty) => { + #[test] + fn $name() { + let path = [[0.0 as $scalar, 0.0], [10.0, 0.0], [12.0, 8.0]]; + let variable = path.map(|point| StrokeVertex::new(point, 2.0)); + let minimum = (0.01 * core::f64::consts::PI) as $scalar; + for math in [MathMode::Integer, MathMode::Float] { + for (angle, round_control, miter_control) in [ + (<$scalar>::NAN, minimum, minimum), + (<$scalar>::NEG_INFINITY, minimum, minimum), + ( + <$scalar>::INFINITY, + (0.25 * core::f64::consts::PI) as $scalar, + (0.99 * core::f64::consts::PI) as $scalar, + ), + ] { + let stroke = |a| { + path.stroke( + StrokeStyle::new(2.0) + .math(math) + .line_join(LineJoin::Round(a)) + .start_cap(LineCap::Round(a)) + .end_cap(LineCap::Round(a)), + false, + ) + }; + let expected = stroke(round_control); + assert!(!expected.is_empty()); + assert_eq!(stroke(angle), expected); + + for (join, control) in [ + (LineJoin::Miter(angle), LineJoin::Miter(miter_control)), + (LineJoin::Round(angle), LineJoin::Round(round_control)), + ] { + let expected = path.outline(&OutlineStyle::new(1.0).math(math).line_join(control)); + assert!(!expected.is_empty()); + assert_eq!( + path.outline(&OutlineStyle::new(1.0).math(math).line_join(join)), + expected + ); + } + + let stroke = + |a| variable.variable_stroke(VariableStrokeStyle::new().math(math).round_angle(a)); + let expected = stroke(round_control); + assert!(!expected.is_empty()); + assert_eq!(stroke(angle), expected); + } + } + } + }; +} + +non_finite_style_angles!(non_finite_angles_f32, f32); +non_finite_style_angles!(non_finite_angles_f64, f64); diff --git a/iOverlay/tests/int_resource_tests.rs b/iOverlay/tests/int_resource_tests.rs new file mode 100644 index 00000000..8d457275 --- /dev/null +++ b/iOverlay/tests/int_resource_tests.rs @@ -0,0 +1,234 @@ +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::integer::OverlayInt; +use i_overlay::core::overlay::{ContourDirection, IntOverlayOptions, Overlay, ShapeType}; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::relate::{IntRelate, PredicateOverlay}; +use i_overlay::core::simplify::Simplify; +use i_overlay::core::single::SingleIntOverlay; +use i_overlay::core::solver::Solver; +use i_overlay::i_float::int::point::IntPoint; +use i_overlay::i_shape::flat::buffer::{FlatContoursBuffer, FlatShapesBuffer}; +use i_overlay::i_shape::int::shape::IntShapes; +use i_overlay::i_shape::source::int::resource::IntShapeResource; +use i_overlay::string::clip::{ClipRule, IntClip}; +use i_overlay::string::overlay::StringOverlay; +use i_overlay::string::slice::IntSlice; + +fn rect(x0: u32, y0: u32, x1: u32, y1: u32) -> [IntPoint; 4] { + [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| IntPoint::new(I::from_u32(x), I::from_u32(y))) +} + +// A user-defined resource must work without collecting its paths into owned containers. +struct BorrowedContour<'a, I: OverlayInt>(&'a [IntPoint]); + +impl IntShapeResource for BorrowedContour<'_, I> { + type ResourceIter<'a> + = core::iter::Once<&'a [IntPoint]> + where + Self: 'a, + I: 'a; + + fn iter_paths(&self) -> Self::ResourceIter<'_> { + core::iter::once(self.0) + } +} + +fn check_engines() { + let subj = rect::(0, 0, 10, 10); + let clip = rect::(5, 0, 15, 10); + let contours = vec![subj.to_vec()]; + let shapes = vec![contours.clone()]; + let borrowed = [subj.as_slice()]; + let custom = BorrowedContour(subj.as_slice()); + let mut flat_contours = FlatContoursBuffer::default(); + flat_contours.set_with_shape(&contours); + let mut flat_shapes = FlatShapesBuffer::default(); + flat_shapes.set_with_shapes(&vec![vec![clip.to_vec()]]); + for rule in [ + OverlayRule::Union, + OverlayRule::Intersect, + OverlayRule::Difference, + OverlayRule::Xor, + ] { + let expected = Overlay::with_contour(&subj, &clip).overlay(rule, FillRule::NonZero); + assert_eq!( + Overlay::from_subj_and_clip(&subj, &flat_shapes).overlay(rule, FillRule::NonZero), + expected + ); + assert_eq!( + Overlay::from_subj_and_clip(&contours[..], &clip[..]).overlay(rule, FillRule::NonZero), + expected + ); + assert_eq!( + Overlay::from_subj_and_clip(&shapes, &flat_shapes).overlay(rule, FillRule::NonZero), + expected + ); + assert_eq!( + Overlay::from_subj_and_clip(&borrowed[..], &clip).overlay(rule, FillRule::NonZero), + expected + ); + assert_eq!( + Overlay::from_subj_and_clip(&flat_contours, &flat_shapes).overlay(rule, FillRule::NonZero), + expected + ); + assert_eq!(custom.overlay(&clip[..], rule, FillRule::NonZero), expected); + assert_eq!(subj[..].overlay(&flat_shapes, rule, FillRule::NonZero), expected); + } + assert!(custom.intersects(&flat_shapes)); + assert!(flat_contours.interiors_intersect(&clip[..])); + assert!(!subj.touches(&clip)); + assert!(!subj.point_intersects(&clip)); + assert!(!subj.within(&clip)); + assert!(!subj.disjoint(&clip)); + assert!(subj.covers(&rect::(2, 2, 8, 8))); + let touch = rect::(10, 10, 20, 20); + assert!(subj.touches(&touch)); + assert!(subj.point_intersects(&touch)); + assert!(subj.disjoint(&rect::(20, 20, 30, 30))); + + let expected = subj[..].simplify(FillRule::NonZero, Default::default()); + assert_eq!(custom.simplify(FillRule::NonZero, Default::default()), expected); + assert_eq!( + flat_contours.simplify(FillRule::NonZero, Default::default()), + expected + ); + assert_eq!(shapes.simplify(FillRule::NonZero, Default::default()), expected); +} + +#[test] +fn resource_storage_and_integer_engines() { + check_engines::(); + check_engines::(); + check_engines::(); +} + +#[test] +fn overlay_reinit_retains_options_and_replaces_both_operands() { + let square = rect::(0, 0, 10, 10); + let other = rect::(5, 0, 15, 10); + let options = IntOverlayOptions { + output_direction: ContourDirection::Clockwise, + ..Default::default() + }; + let mut overlay = Overlay::from_subj_and_clip_custom(&square, &other, options, Solver::LIST); + overlay.overlay(OverlayRule::Union, FillRule::NonZero); + overlay.reinit_with_subj(&square[..]); + let expected = Overlay::from_subj_custom(&square, options, Solver::LIST) + .overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(overlay.overlay(OverlayRule::Subject, FillRule::NonZero), expected); + assert_eq!(overlay.options.output_direction, ContourDirection::Clockwise); + assert_eq!(overlay.solver.strategy, Solver::LIST.strategy); + overlay.reinit_with_subj_and_clip(&square, &square[..]); + assert!( + overlay + .overlay(OverlayRule::Difference, FillRule::NonZero) + .is_empty() + ); + let empty: &[IntPoint] = &[]; + overlay.reinit_with_subj(empty); + assert!( + overlay + .overlay(OverlayRule::Subject, FillRule::NonZero) + .is_empty() + ); + overlay.add_source(&square, ShapeType::Subject); + assert_eq!(overlay.overlay(OverlayRule::Subject, FillRule::NonZero), expected); +} + +#[test] +fn predicate_fill_rule_and_reinit() { + let square = rect::(0, 0, 10, 10); + let duplicate = [square.as_slice(), square.as_slice()]; + assert!(!PredicateOverlay::from_subj_and_clip(&duplicate, &square).interiors_intersect()); + let mut predicate = + PredicateOverlay::from_subj_and_clip_custom(&duplicate, &square, FillRule::NonZero, Solver::LIST); + assert!(predicate.interiors_intersect()); + predicate.reinit_with_subj_and_clip(&duplicate[..], &square[..]); + assert!(predicate.interiors_intersect()); + let empty: &[IntPoint] = &[]; + predicate.reinit_with_subj_and_clip(empty, &square); + assert!(!predicate.intersects()); + assert_eq!(predicate.solver.strategy, Solver::LIST.strategy); +} + +#[test] +fn string_resources_preserve_open_and_closed_semantics() { + let square = rect::(0, 0, 10, 10); + let path = [IntPoint::new(2, 2), IntPoint::new(8, 2), IntPoint::new(8, 8)]; + let rule = ClipRule { + invert: false, + boundary_included: true, + }; + let mut polygon = FlatShapesBuffer::default(); + polygon.set_with_contour(&square); + let mut strings = FlatContoursBuffer::default(); + strings.set_with_contour(&path); + let expected = square.clip_path(&path.to_vec(), FillRule::NonZero, rule); + assert_eq!(polygon.clip_source(&strings, FillRule::NonZero, rule), expected); + assert_eq!( + StringOverlay::from_shape_and_string(&polygon, &path[..]).clip_string_lines(FillRule::NonZero, rule), + expected + ); + let mut closed = StringOverlay::from_shape(&polygon); + closed.add_string_contour_source(&strings); + let mut legacy = StringOverlay::with_shape_contour(&square); + legacy.add_string_contour(&path); + let closed_result = closed.clip_string_lines(FillRule::NonZero, rule); + assert_eq!(closed_result, legacy.clip_string_lines(FillRule::NonZero, rule)); + assert_ne!(closed_result, expected); +} + +#[test] +fn slices_and_clips_work_on_borrowed_and_flat_resources() { + let square = rect::(0, 0, 10, 10); + let line = [IntPoint::new(-5, 5), IntPoint::new(15, 5)]; + let borrowed = [square.as_slice()]; + let mut flat = FlatContoursBuffer::default(); + flat.set_with_contour(&square); + let sliced = square.slice_by_line(line, FillRule::NonZero); + assert_eq!(sliced.len(), 2); + assert_eq!(borrowed[..].slice_by_source(&line, FillRule::NonZero), sliced); + assert_eq!(flat.slice_by_source(&line[..], FillRule::NonZero), sliced); + let rule = ClipRule { + invert: false, + boundary_included: false, + }; + assert_eq!( + flat.clip_source(&line[..], FillRule::NonZero, rule), + vec![vec![IntPoint::new(0, 5), IntPoint::new(10, 5)]] + ); +} + +#[test] +fn simplify_resource_handles_holes_empty_paths_and_area_filter() { + let outer = rect::(0, 0, 20, 20); + let mut hole = rect::(5, 5, 15, 15); + hole.reverse(); + let shape = vec![outer.to_vec(), hole.to_vec()]; + let mut flat = FlatShapesBuffer::default(); + flat.set_with_shape(&shape); + let expected = Overlay::with_contours(&shape, &[]).overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(expected[0].len(), 2); + assert_eq!(flat.simplify(FillRule::NonZero, Default::default()), expected); + let paths = [outer.as_slice(), &[], hole.as_slice()]; + assert_eq!( + paths[..].simplify(FillRule::NonZero, Default::default()), + expected + ); + let mut reusable = Overlay::from_subj(&outer); + let empty: IntShapes = vec![]; + assert!(reusable.simplify_source(&empty, FillRule::NonZero).is_empty()); + let degenerate = [IntPoint::new(0, 0), IntPoint::new(1, 1)]; + assert!( + degenerate + .simplify(FillRule::NonZero, Default::default()) + .is_empty() + ); + let options = IntOverlayOptions { + min_output_area: 1_000, + ..Default::default() + }; + assert!(outer.simplify(FillRule::NonZero, options).is_empty()); + flat.set_with_contour(&outer); + assert!(flat.simplify(FillRule::NonZero, options).is_empty()); +} diff --git a/iOverlay/tests/miter_regression_tests.rs b/iOverlay/tests/miter_regression_tests.rs index 8cdeda06..e8101718 100644 --- a/iOverlay/tests/miter_regression_tests.rs +++ b/iOverlay/tests/miter_regression_tests.rs @@ -14,7 +14,7 @@ fn miter_on_nearly_collinear_path_stays_near_input() { IntPoint::new(7_914_439, 12_537), ]; let width = 200_000; - let style = IntStrokeStyle::new(width).line_join(IntLineJoin::Miter(Angle::from_radians(0.1))); + let style = IntStrokeStyle::new(width).line_join(IntLineJoin::Miter(Angle::from_radians(0.1).unwrap())); path.validate_stroke(&style).unwrap(); let shapes = path.stroke(&style, false).unwrap(); @@ -41,7 +41,8 @@ fn nearly_collinear_shrink_outline_stays_inside_input_bounds() { IntPoint::new(7_914_439, 12_537), IntPoint::new(-2_000_000, 6_000_000), ]; - let style = IntOutlineStyle::new(-100_000).line_join(IntLineJoin::Miter(Angle::from_radians(0.1))); + let style = + IntOutlineStyle::new(-100_000).line_join(IntLineJoin::Miter(Angle::from_radians(0.1).unwrap())); contour.validate_outline(&style).unwrap(); let shapes = contour.outline(&style).unwrap(); assert_eq!(shapes.len(), 1); @@ -66,7 +67,7 @@ fn only_integer_math_bevels_turns_below_five_degrees() { let bevel = path.stroke(&base, false).unwrap(); let miter = path .stroke( - &base.line_join(IntLineJoin::Miter(Angle::from_radians(0.1))), + &base.line_join(IntLineJoin::Miter(Angle::from_radians(0.1).unwrap())), false, ) .unwrap(); @@ -93,7 +94,7 @@ fn only_integer_math_clips_corners_sharper_than_five_degrees() { for math in [MathMode::Integer, MathMode::Float] { let style = IntStrokeStyle::new(20_000) .math(math) - .line_join(IntLineJoin::Miter(Angle::from_radians(0.01))); + .line_join(IntLineJoin::Miter(Angle::from_radians(0.01).unwrap())); path.validate_stroke(&style).unwrap(); let shapes = path.stroke(&style, false).unwrap(); let max_x = shapes.iter().flatten().flatten().map(|p| p.x).max().unwrap(); diff --git a/iOverlay/tests/outline_variable_math_tests.rs b/iOverlay/tests/outline_variable_math_tests.rs index afe45689..70df34d1 100644 --- a/iOverlay/tests/outline_variable_math_tests.rs +++ b/iOverlay/tests/outline_variable_math_tests.rs @@ -40,7 +40,7 @@ macro_rules! area_checks { for offset in [-200, 0, 200] { let style = IntOutlineStyle::new(offset) .math(math) - .line_join(IntLineJoin::Miter(Angle::from_radians(0.1))); + .line_join(IntLineJoin::Miter(Angle::from_radians(0.1).unwrap())); square.validate_outline(&style).unwrap(); let shapes = square.outline(&style).unwrap(); let side = 4000.0 + 2.0 * offset as f64; @@ -62,7 +62,7 @@ macro_rules! area_checks { IntStrokeVertex::new(IntPoint::new(2400, 3200), 400), ]; let style = IntVariableStrokeStyle::new().math(math).arc(ArcOptions { - max_step: Angle::from_radians(0.03), + max_step: Angle::from_radians(0.03).unwrap(), ..Default::default() }); path.validate_variable_stroke().unwrap(); @@ -101,7 +101,7 @@ fn float_math_preserves_local_geometry_at_large_origins() { let translated = outline.map(|p| IntPoint::new(p.x + shift, p.y - shift)); for join in [ IntLineJoin::Bevel, - IntLineJoin::Miter(Angle::from_radians(0.1)), + IntLineJoin::Miter(Angle::from_radians(0.1).unwrap()), IntLineJoin::Round(ArcOptions::default()), ] { let style = IntOutlineStyle::new(400).math(MathMode::Float).line_join(join); @@ -153,7 +153,7 @@ fn float_adapters_forward_math_and_replace_flat_output() { let int_style = IntOutlineStyle::new(400) .math(math) .line_join(IntLineJoin::Round(ArcOptions { - max_step: Angle::from_radians(0.1), + max_step: Angle::from_radians(0.1).unwrap(), ..Default::default() })); // The adapter may translate the origin, so compare translation-invariant areas. @@ -245,7 +245,7 @@ fn outline_preserves_hole_roles_in_both_modes() { for offset in [-200, 0, 200] { let style = IntOutlineStyle::new(offset) .math(math) - .line_join(IntLineJoin::Miter(Angle::from_radians(0.1))); + .line_join(IntLineJoin::Miter(Angle::from_radians(0.1).unwrap())); let shapes = paths.outline(&style).unwrap(); assert_eq!(shapes.len(), 1); assert_eq!(shapes[0].len(), 2); @@ -264,7 +264,7 @@ fn outline_bounds_use_the_selected_miter_limit() { IntPoint::new(0, 1000), ]; // Integer math clamps to 5 degrees; Float uses the requested 1.8 degrees. - let style = IntOutlineStyle::new(300).line_join(IntLineJoin::Miter(Angle::from_radians(0.01))); + let style = IntOutlineStyle::new(300).line_join(IntLineJoin::Miter(Angle::from_radians(0.01).unwrap())); assert!(path.validate_outline(&style.math(MathMode::Integer)).is_ok()); assert!(path.validate_outline(&style.math(MathMode::Float)).is_err()); } diff --git a/iOverlay/tests/stroke_bench.rs b/iOverlay/tests/stroke_bench.rs index 2e268a32..cc9b0a62 100644 --- a/iOverlay/tests/stroke_bench.rs +++ b/iOverlay/tests/stroke_bench.rs @@ -65,7 +65,7 @@ fn benchmark() { } for join in [ IntLineJoin::Bevel, - IntLineJoin::Miter(Angle::from_radians(0.1)), + IntLineJoin::Miter(Angle::from_radians(0.1).unwrap()), IntLineJoin::Round(ArcOptions::default()), ] { for math in [MathMode::Integer, MathMode::Float] { diff --git a/iOverlay/tests/stroke_math_tests.rs b/iOverlay/tests/stroke_math_tests.rs index a7e679dd..6dfc09b6 100644 --- a/iOverlay/tests/stroke_math_tests.rs +++ b/iOverlay/tests/stroke_math_tests.rs @@ -61,7 +61,7 @@ fn float_math_preserves_local_geometry_at_large_i64_origins() { let translated = path.map(|p| IntPoint::new(p.x + shift, p.y - shift)); for join in [ IntLineJoin::Bevel, - IntLineJoin::Miter(Angle::from_radians(0.1)), + IntLineJoin::Miter(Angle::from_radians(0.1).unwrap()), IntLineJoin::Round(ArcOptions::default()), ] { let style = IntStrokeStyle::new(400) From 3afe99da8cc305e9afcfbac968ffb83a5faa78a4 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Fri, 18 Sep 2026 21:18:49 +0300 Subject: [PATCH 34/44] migrate to new source api --- iOverlay/README.md | 2 +- iOverlay/src/build/string.rs | 2 +- iOverlay/src/core/extract.rs | 2 +- iOverlay/src/core/hierarchy.rs | 8 +- iOverlay/src/core/overlay.rs | 48 +++++--- iOverlay/src/core/relate.rs | 22 ++-- iOverlay/src/core/simplify.rs | 7 +- iOverlay/src/mesh/int/outline/build.rs | 2 +- iOverlay/src/string/clip.rs | 2 +- iOverlay/src/string/extract.rs | 4 +- iOverlay/src/string/overlay.rs | 10 +- iOverlay/src/string/slice.rs | 2 +- iOverlay/src/vector/extract.rs | 16 +-- iOverlay/tests/board_tests.rs | 4 +- iOverlay/tests/clip_coverage_tests.rs | 4 +- iOverlay/tests/clip_oblique_tests.rs | 2 +- iOverlay/tests/crash_tests.rs | 4 +- iOverlay/tests/critical_hypotheses_tests.rs | 7 +- iOverlay/tests/direction_tests.rs | 2 +- iOverlay/tests/dynamic_tests.rs | 35 +++--- iOverlay/tests/fragment_tests.rs | 110 ++++++++++++------ iOverlay/tests/hierarchy_stress.rs | 4 +- iOverlay/tests/hierarchy_touch_tests.rs | 6 +- iOverlay/tests/int_resource_tests.rs | 3 + iOverlay/tests/integer_grid_oracle_tests.rs | 2 +- iOverlay/tests/integer_invariance_tests.rs | 9 +- iOverlay/tests/integer_range_tests.rs | 10 +- iOverlay/tests/issue_91_tests.rs | 2 +- iOverlay/tests/ocg_tests.rs | 41 +++---- iOverlay/tests/ogc_area_filter_tests.rs | 2 +- iOverlay/tests/ogc_hole_orientation_tests.rs | 13 +-- iOverlay/tests/ogc_traversal_tests.rs | 2 +- iOverlay/tests/ogc_winding_tests.rs | 9 +- iOverlay/tests/outline_cell_oracle_tests.rs | 2 +- iOverlay/tests/outline_grid_area_tests.rs | 4 +- iOverlay/tests/outline_identity_tests.rs | 2 +- iOverlay/tests/output_consistency_tests.rs | 2 +- iOverlay/tests/overlay_tests.rs | 12 +- iOverlay/tests/predicate_cell_oracle_tests.rs | 4 +- iOverlay/tests/resolved_geometry_tests.rs | 3 +- iOverlay/tests/slice_area_tests.rs | 6 +- iOverlay/tests/slice_partition_tests.rs | 4 +- 42 files changed, 255 insertions(+), 182 deletions(-) diff --git a/iOverlay/README.md b/iOverlay/README.md index cca6955a..090c4d19 100644 --- a/iOverlay/README.md +++ b/iOverlay/README.md @@ -214,7 +214,7 @@ let subject = int_shape![ [[20, 20], [80, 20], [80, 80], [20, 80]], ]; -let mut overlay = Overlay::with_contours(&subject, &[]); +let mut overlay = Overlay::from_subj(&subject); let result = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::EvenOdd); assert_eq!(result.shapes.shape_ranges.len(), 2); diff --git a/iOverlay/src/build/string.rs b/iOverlay/src/build/string.rs index b91063fc..1c7c0946 100644 --- a/iOverlay/src/build/string.rs +++ b/iOverlay/src/build/string.rs @@ -246,7 +246,7 @@ mod tests { } fn clip_path(shape: &[IntPoint], path: &[IntPoint], invert: bool) -> Vec>> { - let mut overlay = StringOverlay::with_shape_contour(shape); + let mut overlay = StringOverlay::from_shape(shape); overlay.add_string_path(path); overlay.clip_string_lines(FillRule::NonZero, clip_rule(invert)) } diff --git a/iOverlay/src/core/extract.rs b/iOverlay/src/core/extract.rs index 821d942e..6ba1d6c9 100644 --- a/iOverlay/src/core/extract.rs +++ b/iOverlay/src/core/extract.rs @@ -610,7 +610,7 @@ mod tests { ]; let mut buffer = Default::default(); - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let shapes_0 = overlay .build_graph_view(FillRule::NonZero) diff --git a/iOverlay/src/core/hierarchy.rs b/iOverlay/src/core/hierarchy.rs index 52bcbf7b..1185ff0b 100644 --- a/iOverlay/src/core/hierarchy.rs +++ b/iOverlay/src/core/hierarchy.rs @@ -198,9 +198,9 @@ mod tests { [[40, 40], [60, 40], [60, 60], [40, 60]], ]; - let mut overlay = Overlay::with_contours(&subject, &[]); + let mut overlay = Overlay::from_subj(&subject); let hierarchy = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::EvenOdd); - let mut regular_overlay = Overlay::with_contours(&subject, &[]); + let mut regular_overlay = Overlay::from_subj(&subject); let regular_shapes = regular_overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); assert_eq!(hierarchy.shapes.to_shapes(), regular_shapes); @@ -232,7 +232,7 @@ mod tests { [[60, 60], [70, 60], [70, 70], [60, 70]], ]; - let mut overlay = Overlay::with_contours(&subject, &[]); + let mut overlay = Overlay::from_subj(&subject); let hierarchy = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::EvenOdd); assert_eq!(hierarchy.shapes.shape_ranges, vec![0..2, 2..3, 3..4]); @@ -263,7 +263,7 @@ mod tests { [[200, 0], [210, 0], [210, 10], [200, 10]], ]; - let mut overlay = Overlay::with_contours(&subject, &[]); + let mut overlay = Overlay::from_subj(&subject); overlay.options.output_direction = ContourDirection::Clockwise; let hierarchy = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::EvenOdd); diff --git a/iOverlay/src/core/overlay.rs b/iOverlay/src/core/overlay.rs index 072a8a9c..ea221322 100644 --- a/iOverlay/src/core/overlay.rs +++ b/iOverlay/src/core/overlay.rs @@ -188,6 +188,7 @@ where /// Creates a new `Overlay` instance and initializes it with subject and clip contours. /// - `subj`: An array of contours that together define the subject. /// - `clip`: An array of contours that together define the clip. + #[deprecated(note = "Use `from_subj_and_clip` instead.")] pub fn with_contour(subj: &[IntPoint], clip: &[IntPoint]) -> Self { Self::from_subj_and_clip(subj, clip) } @@ -197,6 +198,7 @@ where /// - `clip`: An array of contours that together define the clip. /// - `options`: Adjust custom behavior. /// - `solver`: Type of solver to use. + #[deprecated(note = "Use `from_subj_and_clip_custom` instead.")] pub fn with_contour_custom( subj: &[IntPoint], clip: &[IntPoint], @@ -209,6 +211,7 @@ where /// Creates a new `Overlay` instance and initializes it with subject and clip contours. /// - `subj`: An array of contours that together define the subject shape. /// - `clip`: An array of contours that together define the clip shape. + #[deprecated(note = "Use `from_subj_and_clip` instead.")] pub fn with_contours(subj: &[IntContour], clip: &[IntContour]) -> Self { Self::from_subj_and_clip(subj, clip) } @@ -218,6 +221,7 @@ where /// - `clip`: An array of contours that together define the clip shape. /// - `options`: Adjust custom behavior. /// - `solver`: Type of solver to use. + #[deprecated(note = "Use `from_subj_and_clip_custom` instead.")] pub fn with_contours_custom( subj: &[IntContour], clip: &[IntContour], @@ -230,6 +234,7 @@ where /// Creates a new `Overlay` instance and initializes it with subject and clip shapes. /// - `subj`: An array of shapes to be used as the subject in the overlay operation. /// - `clip`: An array of shapes to be used as the clip in the overlay operation. + #[deprecated(note = "Use `from_subj_and_clip` instead.")] pub fn with_shapes(subj: &[IntShape], clip: &[IntShape]) -> Self { Self::from_subj_and_clip(subj, clip) } @@ -239,6 +244,7 @@ where /// - `clip`: An array of shapes to be used as the clip in the overlay operation. /// - `options`: Adjust custom behavior. /// - `solver`: Type of solver to use. + #[deprecated(note = "Use `from_subj_and_clip_custom` instead.")] pub fn with_shapes_options( subj: &[IntShape], clip: &[IntShape], @@ -275,6 +281,7 @@ where /// - `contours`: An array of `IntContour` instances to be added to the overlay. /// - `shape_type`: Specifies the role of the added paths in the overlay operation, either as `Subject` or `Clip`. #[inline] + #[deprecated(note = "Use `add_source` instead.")] pub fn add_contours(&mut self, contours: &[IntContour], shape_type: ShapeType) { for contour in contours.iter() { self.add_contour(contour, shape_type); @@ -285,17 +292,19 @@ where /// - `shape`: A reference to a `IntShape` instance to be added. /// - `shape_type`: Specifies the role of the added shape in the overlay operation, either as `Subject` or `Clip`. #[inline] + #[deprecated(note = "Use `add_source` instead.")] pub fn add_shape(&mut self, shape: &IntShape, shape_type: ShapeType) { - self.add_contours(shape, shape_type); + self.add_source(shape, shape_type); } /// Adds multiple shapes to the overlay as either subject or clip shapes. /// - `shapes`: An array of `IntShape` instances to be added to the overlay. /// - `shape_type`: Specifies the role of the added shapes in the overlay operation, either as `Subject` or `Clip`. #[inline] + #[deprecated(note = "Use `add_source` instead.")] pub fn add_shapes(&mut self, shapes: &[IntShape], shape_type: ShapeType) { for shape in shapes.iter() { - self.add_contours(shape, shape_type); + self.add_source(shape, shape_type); } } @@ -308,6 +317,7 @@ where /// - `buffer`: A buffer of `IntShapes` instances to be added to the overlay. /// - `shape_type`: Specifies the role of the added shapes in the overlay operation, either as `Subject` or `Clip`. #[inline] + #[deprecated(note = "Use `add_source` instead.")] pub fn add_flat_buffer(&mut self, buffer: &FlatContoursBuffer, shape_type: ShapeType) { for range in buffer.ranges.iter() { let contour = &buffer.points[range.clone()]; @@ -403,7 +413,7 @@ where /// /// let left_rect = [int_pnt!(0, 0), int_pnt!(0, 10), int_pnt!(10, 10), int_pnt!(10, 0)]; /// let right_rect = [int_pnt!(10, 0), int_pnt!(10, 10), int_pnt!(20, 10), int_pnt!(20, 0)]; - /// let mut overlay = Overlay::with_contour(&left_rect, &right_rect); + /// let mut overlay = Overlay::from_subj_and_clip(&left_rect, &right_rect); /// /// let result = overlay.overlay(OverlayRule::Union, FillRule::EvenOdd); /// ``` @@ -559,7 +569,7 @@ mod tests { IntPoint::new(0, 10), ]]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); assert_eq!(result.len(), 1); @@ -586,7 +596,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); assert_eq!(result.len(), 1); @@ -613,7 +623,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); assert_eq!(result.len(), 1); @@ -642,7 +652,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -674,7 +684,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -700,7 +710,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -738,7 +748,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -764,7 +774,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -790,7 +800,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -816,7 +826,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 2); @@ -842,7 +852,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -867,7 +877,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -892,7 +902,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -917,7 +927,7 @@ mod tests { ], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -934,7 +944,7 @@ mod tests { vec![IntPoint::new(0, 0), IntPoint::new(-2, 0), IntPoint::new(0, 2)], ]; - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 1); @@ -946,7 +956,7 @@ mod tests { fn test_empty_input() { let subj: &[IntContour] = &[]; - let mut overlay = Overlay::with_contours(subj, &[]); + let mut overlay = Overlay::from_subj(subj); let result = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(result.len(), 0); diff --git a/iOverlay/src/core/relate.rs b/iOverlay/src/core/relate.rs index 2fe95201..0d1be28c 100644 --- a/iOverlay/src/core/relate.rs +++ b/iOverlay/src/core/relate.rs @@ -196,6 +196,7 @@ where /// - `contours`: An array of `IntContour` instances to be added to the overlay. /// - `shape_type`: Specifies the role of the added paths in the overlay operation, either as `Subject` or `Clip`. #[inline] + #[deprecated(note = "Use `add_source` instead.")] pub fn add_contours(&mut self, contours: &[IntContour], shape_type: ShapeType) { for contour in contours.iter() { self.add_contour(contour, shape_type); @@ -206,17 +207,19 @@ where /// - `shape`: A reference to a `IntShape` instance to be added. /// - `shape_type`: Specifies the role of the added shape in the overlay operation, either as `Subject` or `Clip`. #[inline] + #[deprecated(note = "Use `add_source` instead.")] pub fn add_shape(&mut self, shape: &IntShape, shape_type: ShapeType) { - self.add_contours(shape, shape_type); + self.add_source(shape, shape_type); } /// Adds multiple shapes to the overlay as either subject or clip shapes. /// - `shapes`: An array of `IntShape` instances to be added to the overlay. /// - `shape_type`: Specifies the role of the added shapes in the overlay operation, either as `Subject` or `Clip`. #[inline] + #[deprecated(note = "Use `add_source` instead.")] pub fn add_shapes(&mut self, shapes: &[IntShape], shape_type: ShapeType) { for shape in shapes.iter() { - self.add_contours(shape, shape_type); + self.add_source(shape, shape_type); } } @@ -381,6 +384,7 @@ mod tests { } #[test] + #[allow(deprecated)] fn test_add_contours() { let mut overlay = PredicateOverlay::new(16); let contours = vec![square(0, 0, 5), square(10, 10, 5)]; @@ -390,6 +394,7 @@ mod tests { } #[test] + #[allow(deprecated)] fn test_add_shape() { let mut overlay = PredicateOverlay::new(16); let shape = vec![square(0, 0, 10)]; @@ -399,6 +404,7 @@ mod tests { } #[test] + #[allow(deprecated)] fn test_add_shapes() { let mut overlay = PredicateOverlay::new(16); let shapes = vec![vec![square(0, 0, 5)], vec![square(20, 20, 5)]]; @@ -564,7 +570,7 @@ mod tests { // are still correctly tracked for point coincidence detection. let mut overlay = PredicateOverlay::new(32); let doughnut_shape = doughnut(0, 0, 30, 10, 10, 10); - overlay.add_shape(&doughnut_shape, ShapeType::Subject); + overlay.add_source(&doughnut_shape, ShapeType::Subject); overlay.add_contour(&diamond(15, 15, 5), ShapeType::Clip); assert!( overlay.intersects(), @@ -577,7 +583,7 @@ mod tests { // Same setup: diamond corners touch the hole boundary but don't overlap let mut overlay = PredicateOverlay::new(32); let doughnut_shape = doughnut(0, 0, 30, 10, 10, 10); - overlay.add_shape(&doughnut_shape, ShapeType::Subject); + overlay.add_source(&doughnut_shape, ShapeType::Subject); overlay.add_contour(&diamond(15, 15, 5), ShapeType::Clip); assert!(overlay.touches(), "diamond touching hole boundary should touch"); } @@ -587,7 +593,7 @@ mod tests { // Same setup: diamond only touches at boundary points, interiors don't overlap let mut overlay = PredicateOverlay::new(32); let doughnut_shape = doughnut(0, 0, 30, 10, 10, 10); - overlay.add_shape(&doughnut_shape, ShapeType::Subject); + overlay.add_source(&doughnut_shape, ShapeType::Subject); overlay.add_contour(&diamond(15, 15, 5), ShapeType::Clip); assert!( !overlay.interiors_intersect(), @@ -601,7 +607,7 @@ mod tests { // Diamond centered at (15,15) with radius 2 (corners at 13,15,17,15 etc) let mut overlay = PredicateOverlay::new(32); let doughnut_shape = doughnut(0, 0, 30, 10, 10, 10); - overlay.add_shape(&doughnut_shape, ShapeType::Subject); + overlay.add_source(&doughnut_shape, ShapeType::Subject); overlay.add_contour(&diamond(15, 15, 2), ShapeType::Clip); assert!(!overlay.intersects(), "diamond inside hole should not intersect"); assert!(!overlay.touches(), "diamond inside hole should not touch"); @@ -622,7 +628,7 @@ mod tests { ]; let mut overlay = PredicateOverlay::new(32); - overlay.add_shape(&doughnut(0, 0, 30, 10, 10, 10), ShapeType::Subject); + overlay.add_source(&doughnut(0, 0, 30, 10, 10, 10), ShapeType::Subject); overlay.add_contour(&diamond_touching_corner, ShapeType::Clip); assert!( @@ -647,7 +653,7 @@ mod tests { ]; let mut overlay = PredicateOverlay::new(32); - overlay.add_shape(&doughnut(0, 0, 30, 10, 10, 10), ShapeType::Subject); + overlay.add_source(&doughnut(0, 0, 30, 10, 10, 10), ShapeType::Subject); overlay.add_contour(&diamond_outside, ShapeType::Clip); assert!( diff --git a/iOverlay/src/core/simplify.rs b/iOverlay/src/core/simplify.rs index 3cd566e1..23b066f1 100644 --- a/iOverlay/src/core/simplify.rs +++ b/iOverlay/src/core/simplify.rs @@ -163,14 +163,15 @@ where return self.simplify_contour(&shape[0], fill_rule); } self.clear(); - self.add_contours(shape, ShapeType::Subject); + self.add_source(shape, ShapeType::Subject); Some(self.overlay(OverlayRule::Subject, fill_rule)) } #[inline] + #[deprecated(note = "Use `simplify_source` instead.")] pub fn simplify_shapes(&mut self, shapes: &[IntShape], fill_rule: FillRule) -> IntShapes { self.clear(); - self.add_shapes(shapes, ShapeType::Subject); + self.add_source(shapes, ShapeType::Subject); self.overlay(OverlayRule::Subject, fill_rule) } @@ -199,7 +200,7 @@ where return; } } else { - self.add_flat_buffer(flat_buffer, ShapeType::Subject); + self.add_source(flat_buffer, ShapeType::Subject); self.split_solver.split_segments(&mut self.segments, &self.solver); if self.segments.is_empty() { flat_buffer.clear_and_reserve(0, 0); diff --git a/iOverlay/src/mesh/int/outline/build.rs b/iOverlay/src/mesh/int/outline/build.rs index eddafb33..40d3dc35 100644 --- a/iOverlay/src/mesh/int/outline/build.rs +++ b/iOverlay/src/mesh/int/outline/build.rs @@ -93,7 +93,7 @@ where contour_overlay.add_segments(&segments); if let Some(graph) = contour_overlay.build_graph_view(fill) { graph.extract_contours_into(OverlayRule::Subject, &mut extraction, &mut contours); - overlay.add_flat_buffer(&contours, ShapeType::Subject); + overlay.add_source(&contours, ShapeType::Subject); } } overlay diff --git a/iOverlay/src/string/clip.rs b/iOverlay/src/string/clip.rs index abefb9b9..e4e4d24a 100644 --- a/iOverlay/src/string/clip.rs +++ b/iOverlay/src/string/clip.rs @@ -201,7 +201,7 @@ where #[inline] fn clip_paths(&self, paths: &[IntPath], fill_rule: FillRule, clip_rule: ClipRule) -> Vec> { let mut overlay = StringOverlay::from_shape(self); - overlay.add_string_paths(paths); + overlay.add_string_source(paths); overlay.clip_string_lines(fill_rule, clip_rule) } } diff --git a/iOverlay/src/string/extract.rs b/iOverlay/src/string/extract.rs index ffe272ba..23e79d78 100644 --- a/iOverlay/src/string/extract.rs +++ b/iOverlay/src/string/extract.rs @@ -255,7 +255,7 @@ mod tests { IntPoint::new(5, -5), ]; - let mut overlay = StringOverlay::with_shape(&paths); + let mut overlay = StringOverlay::from_shape(&paths); overlay.add_string_contour(&window); let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); @@ -280,7 +280,7 @@ mod tests { IntPoint::new(5, -5), ]; - let mut overlay = StringOverlay::::with_shape(&paths); + let mut overlay = StringOverlay::::from_shape(&paths); overlay.add_string_contour(&window); let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); diff --git a/iOverlay/src/string/overlay.rs b/iOverlay/src/string/overlay.rs index e5699cf4..b36378da 100644 --- a/iOverlay/src/string/overlay.rs +++ b/iOverlay/src/string/overlay.rs @@ -132,6 +132,7 @@ where /// Creates a new `StringOverlay` instance and initializes it with a single shape contour. /// - `contour`: An array of points that form a closed path. #[inline] + #[deprecated(note = "Use `from_shape` instead.")] pub fn with_shape_contour(contour: &[IntPoint]) -> Self { Self::from_shape(contour) } @@ -139,6 +140,7 @@ where /// Creates a new `StringOverlay` instance and initializes it with multiple shape contours. /// - `contours`: An array of `IntContour` instances to be added to the overlay. #[inline] + #[deprecated(note = "Use `from_shape` instead.")] pub fn with_shape_contours(contours: &[IntContour]) -> Self { Self::from_shape(contours) } @@ -146,6 +148,7 @@ where /// Creates a new `StringOverlay` instance and initializes it with s shape. /// - `shape`: An `IntShape` instances to be added to the overlay. #[inline] + #[deprecated(note = "Use `from_shape` instead.")] pub fn with_shape(shape: &[IntContour]) -> Self { Self::from_shape(shape) } @@ -153,6 +156,7 @@ where /// Creates a new `StringOverlay` instance and initializes it with subject and clip shapes. /// - `shapes`: An array of `IntShape` instances to be added to the overlay. #[inline] + #[deprecated(note = "Use `from_shape` instead.")] pub fn with_shapes(shapes: &[IntShape]) -> Self { Self::from_shape(shapes) } @@ -175,6 +179,7 @@ where /// Adds multiple paths to the overlay as shape paths. /// - `contours`: An array of `IntContour` instances to be added to the overlay. + #[deprecated(note = "Use `add_shape_source` instead.")] pub fn add_shape_contours(&mut self, contours: &[IntContour]) { for contour in contours.iter() { self.add_shape_contour(contour); @@ -184,9 +189,10 @@ where /// Adds a list of shape to the overlay. /// - `shapes`: An array of `IntShape` instances to be added to the overlay. #[inline] + #[deprecated(note = "Use `add_shape_source` instead.")] pub fn add_shapes(&mut self, shapes: &[IntShape]) { for shape in shapes { - self.add_shape_contours(shape); + self.add_shape_source(shape); } } @@ -267,6 +273,7 @@ where /// Adds a string line paths to the overlay. /// - `paths`: A collection of paths, each representing a string line. #[inline] + #[deprecated(note = "Use `add_string_source` instead.")] pub fn add_string_paths(&mut self, paths: &[IntPath]) { for path in paths { self.add_string_path(path); @@ -276,6 +283,7 @@ where /// Adds a string line contours to the overlay. /// - `contours`: A collection of contours, each representing a string line closed path. #[inline] + #[deprecated(note = "Use `add_string_contour_source` instead.")] pub fn add_string_contours(&mut self, contours: &[IntContour]) { for contour in contours { self.add_string_contour(contour); diff --git a/iOverlay/src/string/slice.rs b/iOverlay/src/string/slice.rs index a99cd1d4..1e882625 100644 --- a/iOverlay/src/string/slice.rs +++ b/iOverlay/src/string/slice.rs @@ -69,7 +69,7 @@ where #[inline] fn slice_by_paths(&self, paths: &[IntPath], fill_rule: FillRule) -> IntShapes { let mut overlay = StringOverlay::from_shape(self); - overlay.add_string_paths(paths); + overlay.add_string_source(paths); overlay .build_graph_view(fill_rule) .map(|graph| graph.extract_shapes(StringRule::Slice)) diff --git a/iOverlay/src/vector/extract.rs b/iOverlay/src/vector/extract.rs index 3f632175..3d1c69cd 100644 --- a/iOverlay/src/vector/extract.rs +++ b/iOverlay/src/vector/extract.rs @@ -402,7 +402,7 @@ mod tests { ]; let mut buffer = Default::default(); - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); overlay.options = IntOverlayOptions::keep_all_points(); let shapes = overlay .build_graph_view(FillRule::NonZero) @@ -411,7 +411,7 @@ mod tests { debug_assert!(shapes[0][0].len() == 6); - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); overlay.options = IntOverlayOptions::default(); let shapes = overlay .build_graph_view(FillRule::NonZero) @@ -429,7 +429,7 @@ mod tests { ]; let mut buffer = Default::default(); - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); overlay.options = IntOverlayOptions::default(); let shapes = overlay .build_graph_view(FillRule::NonZero) @@ -448,7 +448,7 @@ mod tests { ]; let mut buffer = Default::default(); - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); let shapes_0 = overlay .build_graph_view(FillRule::NonZero) @@ -475,7 +475,7 @@ mod tests { [[1, 3], [1, 4], [2, 4], [2, 3]] ]; let mut buffer = Default::default(); - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); overlay.options = IntOverlayOptions::default(); let shapes = overlay .build_graph_view(FillRule::NonZero) @@ -493,7 +493,7 @@ mod tests { [[0, 0], [3, 0], [3, -3], [2, -3], [2, 0], [-1, 0], [-1, 3], [-2, 3], [-2, 2], [0, 2], [0, 1], [-3, 1], [-3, 4], [0, 4]], ]; let mut buffer = Default::default(); - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); overlay.options = IntOverlayOptions::default(); let shapes = overlay .build_graph_view(FillRule::NonZero) @@ -516,7 +516,7 @@ mod tests { [[7, 1], [11, 1], [11, 5], [7, 5]], ]; let mut buffer = Default::default(); - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); overlay.options = IntOverlayOptions::default(); let shapes = overlay .build_graph_view(FillRule::NonZero) @@ -535,7 +535,7 @@ mod tests { ]; let mut buffer = Default::default(); - let mut overlay = Overlay::with_contours(&subj, &[]); + let mut overlay = Overlay::from_subj(&subj); overlay.options = IntOverlayOptions { preserve_input_collinear: false, output_direction: ContourDirection::CounterClockwise, diff --git a/iOverlay/tests/board_tests.rs b/iOverlay/tests/board_tests.rs index 7dc851aa..44ec57e2 100644 --- a/iOverlay/tests/board_tests.rs +++ b/iOverlay/tests/board_tests.rs @@ -40,8 +40,8 @@ mod tests { let clip_paths = many_squares(IntPoint::new(15, 15), 20, 30, n - 1); let mut overlay = Overlay::new(8 * n * n); - overlay.add_contours(&subj_paths, ShapeType::Subject); - overlay.add_contours(&clip_paths, ShapeType::Clip); + overlay.add_source(&subj_paths, ShapeType::Subject); + overlay.add_source(&clip_paths, ShapeType::Clip); let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); let result = graph.extract_shapes(rule, &mut Default::default()); diff --git a/iOverlay/tests/clip_coverage_tests.rs b/iOverlay/tests/clip_coverage_tests.rs index 6caca141..a5a40988 100644 --- a/iOverlay/tests/clip_coverage_tests.rs +++ b/iOverlay/tests/clip_coverage_tests.rs @@ -71,7 +71,7 @@ fn grid_clipping_preserves_directed_coverage() { inside != invert }) .collect(); - let mut overlay = StringOverlay::with_shape_contour(&subject); + let mut overlay = StringOverlay::from_shape(&subject); overlay.add_string_lines(&lines); let result = overlay.clip_string_lines( FillRule::NonZero, @@ -171,7 +171,7 @@ fn clipping_overlapping_contours_uses_the_resolved_boundary() { inside != invert }) .collect(); - let mut overlay = StringOverlay::with_shape_contours(&contours); + let mut overlay = StringOverlay::from_shape(&contours); overlay.add_string_lines(&lines); let actual = overlay.clip_string_lines( fill, diff --git a/iOverlay/tests/clip_oblique_tests.rs b/iOverlay/tests/clip_oblique_tests.rs index 06b7b12a..e6805cc5 100644 --- a/iOverlay/tests/clip_oblique_tests.rs +++ b/iOverlay/tests/clip_oblique_tests.rs @@ -94,7 +94,7 @@ fn oblique_clipping_preserves_exact_coverage_at_large_translations() { inside != invert }) .collect(); - let mut overlay = StringOverlay::with_shape_contour(&contour); + let mut overlay = StringOverlay::from_shape(&contour); overlay.add_string_lines(&lines); let result = overlay.clip_string_lines_with_solver( FillRule::NonZero, diff --git a/iOverlay/tests/crash_tests.rs b/iOverlay/tests/crash_tests.rs index 03304894..62e0b939 100644 --- a/iOverlay/tests/crash_tests.rs +++ b/iOverlay/tests/crash_tests.rs @@ -42,7 +42,7 @@ mod tests { }; let mut overlay = Overlay::new_custom(4, Default::default(), solver); - overlay.add_contours(&subj, ShapeType::Subject); + overlay.add_source(&subj, ShapeType::Subject); if let Some(graph) = overlay.build_graph_view(FillRule::NonZero) { graph.validate(); let result = graph.extract_shapes(OverlayRule::Subject, &mut Default::default()); @@ -91,7 +91,7 @@ mod tests { ]; for &solver in SOLVERS.iter() { let mut overlay = Overlay::new_custom(4, Default::default(), solver); - overlay.add_contours(&subj_paths, ShapeType::Subject); + overlay.add_source(&subj_paths, ShapeType::Subject); if let Some(graph) = overlay.build_graph_view(FillRule::NonZero) { graph.validate(); let result = graph.extract_shapes(OverlayRule::Subject, &mut Default::default()); diff --git a/iOverlay/tests/critical_hypotheses_tests.rs b/iOverlay/tests/critical_hypotheses_tests.rs index c20b228e..07e0472e 100644 --- a/iOverlay/tests/critical_hypotheses_tests.rs +++ b/iOverlay/tests/critical_hypotheses_tests.rs @@ -70,8 +70,9 @@ fn normalized_overlay + Into>( .map(|p| IntPoint::new(I::try_from(p[0]).ok().unwrap(), I::try_from(p[1]).ok().unwrap())) .collect() }; - let output = Overlay::with_contour_custom(&convert(subj), &convert(clip), Default::default(), solver) - .overlay(rule, FillRule::EvenOdd); + let output = + Overlay::from_subj_and_clip_custom(&convert(subj), &convert(clip), Default::default(), solver) + .overlay(rule, FillRule::EvenOdd); let mut output: Vec>> = output .into_iter() .map(|shape| { @@ -179,7 +180,7 @@ fn hypothesis_predicates_agree_with_boolean_results_on_dense_inputs() { FillRule::Positive, FillRule::Negative, ] { - let mut overlay = Overlay::with_contour(&subj, &clip); + let mut overlay = Overlay::from_subj_and_clip(&subj, &clip); let intersection = overlay.overlay(OverlayRule::Intersect, fill_rule); let difference = overlay.overlay(OverlayRule::Difference, fill_rule); let subject = overlay.overlay(OverlayRule::Subject, fill_rule); diff --git a/iOverlay/tests/direction_tests.rs b/iOverlay/tests/direction_tests.rs index 0c9cda4e..a864f2b6 100644 --- a/iOverlay/tests/direction_tests.rs +++ b/iOverlay/tests/direction_tests.rs @@ -101,7 +101,7 @@ mod tests { ]; // test default behavior - let r = Overlay::with_contours(&path, &[]).overlay(OverlayRule::Subject, FillRule::NonZero); + let r = Overlay::from_subj(&path).overlay(OverlayRule::Subject, FillRule::NonZero); assert!(r[0][0].area_two() > 0i64); assert!(r[0][1].area_two() < 0i64); } diff --git a/iOverlay/tests/dynamic_tests.rs b/iOverlay/tests/dynamic_tests.rs index 64e458fc..6dcf6e46 100644 --- a/iOverlay/tests/dynamic_tests.rs +++ b/iOverlay/tests/dynamic_tests.rs @@ -38,7 +38,7 @@ mod tests { let subj = create_star::(1.0, r, 7, a, scale); if let Some(graph) = - Overlay::with_contours_custom(&subj, &clip, Default::default(), solver) + Overlay::from_subj_and_clip_custom(&subj, &clip, Default::default(), solver) .build_graph_view(FillRule::NonZero) { graph.validate(); @@ -66,8 +66,9 @@ mod tests { let mut a = 0.0; while a < 4.0 * PI { let subj = create_star::(200.0, 30.0, 7, a, scale); - if let Some(graph) = Overlay::with_contours_custom(&subj, &clip, Default::default(), solver) - .build_graph_view(FillRule::NonZero) + if let Some(graph) = + Overlay::from_subj_and_clip_custom(&subj, &clip, Default::default(), solver) + .build_graph_view(FillRule::NonZero) { graph.validate(); let _ = graph.extract_shapes(OverlayRule::Xor, &mut Default::default()); @@ -92,8 +93,9 @@ mod tests { while a < 2.0 * PI { let subj = create_star::(202.5, 33.75, 24, a, scale); - if let Some(graph) = Overlay::with_contours_custom(&subj, &clip, Default::default(), solver) - .build_graph_view(FillRule::NonZero) + if let Some(graph) = + Overlay::from_subj_and_clip_custom(&subj, &clip, Default::default(), solver) + .build_graph_view(FillRule::NonZero) { graph.validate(); let _ = graph.extract_shapes(OverlayRule::Xor, &mut Default::default()); @@ -118,8 +120,9 @@ mod tests { while a < 4.0 * PI { let subj = create_star::(100.0, 10.0, 17, a, scale); - if let Some(graph) = Overlay::with_contours_custom(&subj, &clip, Default::default(), solver) - .build_graph_view(FillRule::NonZero) + if let Some(graph) = + Overlay::from_subj_and_clip_custom(&subj, &clip, Default::default(), solver) + .build_graph_view(FillRule::NonZero) { graph.validate(); let _ = graph.extract_shapes(OverlayRule::Xor, &mut Default::default()); @@ -144,8 +147,9 @@ mod tests { while a < 0.000_001 { let subj = create_star::(202.5, 33.75, 24, a, scale); - if let Some(graph) = Overlay::with_contours_custom(&subj, &clip, Default::default(), solver) - .build_graph_view(FillRule::NonZero) + if let Some(graph) = + Overlay::from_subj_and_clip_custom(&subj, &clip, Default::default(), solver) + .build_graph_view(FillRule::NonZero) { graph.validate(); let _ = graph.extract_shapes(OverlayRule::Xor, &mut Default::default()); @@ -170,7 +174,7 @@ mod tests { // println!("subj {:?}", subj); for &solver in SOLVERS.iter() { - if let Some(graph) = Overlay::with_contours_custom(&subj, &clip, Default::default(), solver) + if let Some(graph) = Overlay::from_subj_and_clip_custom(&subj, &clip, Default::default(), solver) .build_graph_view(FillRule::NonZero) { graph.validate(); @@ -194,8 +198,9 @@ mod tests { while a < 0.000_001 { let subj = create_star::(100.0, 50.0, 24, a, scale); - if let Some(graph) = Overlay::with_contours_custom(&subj, &clip, Default::default(), solver) - .build_graph_view(FillRule::NonZero) + if let Some(graph) = + Overlay::from_subj_and_clip_custom(&subj, &clip, Default::default(), solver) + .build_graph_view(FillRule::NonZero) { graph.validate(); let _ = graph.extract_shapes(OverlayRule::Xor, &mut Default::default()); @@ -276,7 +281,7 @@ mod tests { while a < 2.0 * PI { let subj = create_star::(r0, r, 4, a, scale); if let Some(graph) = - Overlay::with_contours_custom(&subj, &clip, Default::default(), solver) + Overlay::from_subj_and_clip_custom(&subj, &clip, Default::default(), solver) .build_graph_view(FillRule::NonZero) { graph.validate(); @@ -305,7 +310,7 @@ mod tests { let r = 1.01; let subj = create_star::(1.0, r, 7, a, scale); - if let Some(graph) = Overlay::with_contours_custom(&subj, &clip, Default::default(), solver) + if let Some(graph) = Overlay::from_subj_and_clip_custom(&subj, &clip, Default::default(), solver) .build_graph_view(FillRule::NonZero) { graph.validate(); @@ -411,7 +416,7 @@ mod tests { } let mut overlay = Overlay::new_custom(4, Default::default(), Default::default()); - overlay.add_contours(&subj_paths, ShapeType::Subject); + overlay.add_source(&subj_paths, ShapeType::Subject); if let Some(graph) = overlay.build_graph_view(FillRule::NonZero) { graph.validate(); let result = graph.extract_shapes(OverlayRule::Subject, &mut Default::default()); diff --git a/iOverlay/tests/fragment_tests.rs b/iOverlay/tests/fragment_tests.rs index 15ea6904..f3357eb1 100644 --- a/iOverlay/tests/fragment_tests.rs +++ b/iOverlay/tests/fragment_tests.rs @@ -26,7 +26,7 @@ mod tests { let results: Vec<_> = [Solver::LIST, Solver::TREE, Solver::AUTO, Solver::FRAG] .into_iter() .map(|solver| { - let shapes = Overlay::with_contours_custom(&subj, &[], Default::default(), solver) + let shapes = Overlay::from_subj_custom(&subj, Default::default(), solver) .overlay(OverlayRule::Subject, FillRule::EvenOdd); let area2: i64 = shapes .iter() @@ -59,17 +59,29 @@ mod tests { let subj_paths = many_squares(IntPoint::new(0, 0), 20, 30, n); let clip_paths = many_squares(IntPoint::new(15, 15), 20, 30, n - 1); - let list_result = - Overlay::with_contours_custom(&subj_paths, &clip_paths, Default::default(), Solver::LIST) - .overlay(rule, fill); - - let tree_result = - Overlay::with_contours_custom(&subj_paths, &clip_paths, Default::default(), Solver::TREE) - .overlay(rule, fill); - - let frag_result = - Overlay::with_contours_custom(&subj_paths, &clip_paths, Default::default(), Solver::FRAG) - .overlay(rule, fill); + let list_result = Overlay::from_subj_and_clip_custom( + &subj_paths, + &clip_paths, + Default::default(), + Solver::LIST, + ) + .overlay(rule, fill); + + let tree_result = Overlay::from_subj_and_clip_custom( + &subj_paths, + &clip_paths, + Default::default(), + Solver::TREE, + ) + .overlay(rule, fill); + + let frag_result = Overlay::from_subj_and_clip_custom( + &subj_paths, + &clip_paths, + Default::default(), + Solver::FRAG, + ) + .overlay(rule, fill); assert_eq!(list_result, tree_result); assert_eq!(list_result, frag_result); @@ -85,17 +97,29 @@ mod tests { let subj_paths = repeat_xy(square(0, 0, 2), 0, 0, 10, 10, n); let clip_paths = repeat_xy(romb(0, 0, 4), 5, 5, 10, 10, n - 1); - let list_result = - Overlay::with_contours_custom(&subj_paths, &clip_paths, Default::default(), Solver::LIST) - .overlay(rule, fill); - - let tree_result = - Overlay::with_contours_custom(&subj_paths, &clip_paths, Default::default(), Solver::TREE) - .overlay(rule, fill); - - let frag_result = - Overlay::with_contours_custom(&subj_paths, &clip_paths, Default::default(), Solver::FRAG) - .overlay(rule, fill); + let list_result = Overlay::from_subj_and_clip_custom( + &subj_paths, + &clip_paths, + Default::default(), + Solver::LIST, + ) + .overlay(rule, fill); + + let tree_result = Overlay::from_subj_and_clip_custom( + &subj_paths, + &clip_paths, + Default::default(), + Solver::TREE, + ) + .overlay(rule, fill); + + let frag_result = Overlay::from_subj_and_clip_custom( + &subj_paths, + &clip_paths, + Default::default(), + Solver::FRAG, + ) + .overlay(rule, fill); assert_eq!(list_result, tree_result); assert_eq!(list_result, frag_result); @@ -112,17 +136,29 @@ mod tests { let subj_paths = many_lines_x(20, n); let clip_paths = many_lines_y(20, n); - let list_result = - Overlay::with_contours_custom(&subj_paths, &clip_paths, Default::default(), Solver::LIST) - .overlay(rule, fill); - - let tree_result = - Overlay::with_contours_custom(&subj_paths, &clip_paths, Default::default(), Solver::TREE) - .overlay(rule, fill); - - let frag_result = - Overlay::with_contours_custom(&subj_paths, &clip_paths, Default::default(), Solver::FRAG) - .overlay(rule, fill); + let list_result = Overlay::from_subj_and_clip_custom( + &subj_paths, + &clip_paths, + Default::default(), + Solver::LIST, + ) + .overlay(rule, fill); + + let tree_result = Overlay::from_subj_and_clip_custom( + &subj_paths, + &clip_paths, + Default::default(), + Solver::TREE, + ) + .overlay(rule, fill); + + let frag_result = Overlay::from_subj_and_clip_custom( + &subj_paths, + &clip_paths, + Default::default(), + Solver::FRAG, + ) + .overlay(rule, fill); assert_eq!(list_result, tree_result); assert_eq!(list_result, frag_result); @@ -138,17 +174,17 @@ mod tests { let contours = discrete_spiral(n, 4); let mut list_overlay = Overlay::new(n * 8); - list_overlay.add_contours(&contours, ShapeType::Subject); + list_overlay.add_source(&contours, ShapeType::Subject); let list_result = list_overlay.overlay(rule, fill); let mut tree_overlay = Overlay::new(n * 8); - tree_overlay.add_contours(&contours, ShapeType::Subject); + tree_overlay.add_source(&contours, ShapeType::Subject); let tree_result = tree_overlay.overlay(rule, fill); let mut frag_overlay = Overlay::new(n * 8); - frag_overlay.add_contours(&contours, ShapeType::Subject); + frag_overlay.add_source(&contours, ShapeType::Subject); let frag_result = frag_overlay.overlay(rule, fill); diff --git a/iOverlay/tests/hierarchy_stress.rs b/iOverlay/tests/hierarchy_stress.rs index 526d0147..4951f318 100644 --- a/iOverlay/tests/hierarchy_stress.rs +++ b/iOverlay/tests/hierarchy_stress.rs @@ -3,8 +3,8 @@ use i_overlay::core::fill_rule::FillRule; use i_overlay::core::hierarchy::{ChildLink, FlatShapeHierarchy}; use i_overlay::core::overlay::{ContourDirection, Overlay}; use i_overlay::core::overlay_rule::OverlayRule; -use i_shape::int::path::ContourExtension; use i_shape::int::area::UnsafeArea; +use i_shape::int::path::ContourExtension; use std::panic::{AssertUnwindSafe, catch_unwind}; #[test] @@ -56,7 +56,7 @@ fn run_stress_case(seed: u64) { (subject, clip, OverlayRule::Xor) }; - let mut overlay = Overlay::with_contours(&subject, &clip); + let mut overlay = Overlay::from_subj_and_clip(&subject, &clip); if seed & 2 != 0 { overlay.options.output_direction = ContourDirection::Clockwise; } diff --git a/iOverlay/tests/hierarchy_touch_tests.rs b/iOverlay/tests/hierarchy_touch_tests.rs index a56919e9..1f78cd61 100644 --- a/iOverlay/tests/hierarchy_touch_tests.rs +++ b/iOverlay/tests/hierarchy_touch_tests.rs @@ -25,7 +25,7 @@ fn hierarchy_preserves_links_and_honors_collinear_output_option() { ogc, ..Default::default() }; - let mut overlay = Overlay::with_contours_custom(&subject, &[], options, Default::default()); + let mut overlay = Overlay::from_subj_custom(&subject, options, Default::default()); let hierarchy = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::NonZero); let shapes = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); @@ -65,7 +65,7 @@ fn island_touching_hole_boundary_keeps_its_parent() { contour(&[[2, 2], [2, 18], [18, 18], [18, 2]]), contour(&island), ]; - let mut overlay = Overlay::with_contours(&subject, &[]); + let mut overlay = Overlay::from_subj(&subject); overlay.options.ogc = true; overlay.options.output_direction = if clockwise { ContourDirection::Clockwise @@ -112,7 +112,7 @@ fn holes_sharing_one_vertex_keep_their_own_islands() { if reversed_input_order { subject.reverse(); } - let mut overlay = Overlay::with_contours(&subject, &[]); + let mut overlay = Overlay::from_subj(&subject); overlay.options.ogc = true; overlay.options.output_direction = if clockwise { ContourDirection::Clockwise diff --git a/iOverlay/tests/int_resource_tests.rs b/iOverlay/tests/int_resource_tests.rs index 8d457275..a3fee9f1 100644 --- a/iOverlay/tests/int_resource_tests.rs +++ b/iOverlay/tests/int_resource_tests.rs @@ -1,3 +1,6 @@ +// These tests intentionally compare the legacy constructors with the resource API. +#![allow(deprecated)] + use i_overlay::core::fill_rule::FillRule; use i_overlay::core::integer::OverlayInt; use i_overlay::core::overlay::{ContourDirection, IntOverlayOptions, Overlay, ShapeType}; diff --git a/iOverlay/tests/integer_grid_oracle_tests.rs b/iOverlay/tests/integer_grid_oracle_tests.rs index 200f9d1f..57b9f8b4 100644 --- a/iOverlay/tests/integer_grid_oracle_tests.rs +++ b/iOverlay/tests/integer_grid_oracle_tests.rs @@ -85,7 +85,7 @@ fn rectangle_overlays_match_independent_cell_winding() { }; let a = rects(1 + case % 7); let b = rects(1 + case % 5); - let mut overlay = Overlay::with_contours( + let mut overlay = Overlay::from_subj_and_clip( &a.iter().map(Rect::contour).collect::>(), &b.iter().map(Rect::contour).collect::>(), ); diff --git a/iOverlay/tests/integer_invariance_tests.rs b/iOverlay/tests/integer_invariance_tests.rs index f27c6fe4..f4a814ad 100644 --- a/iOverlay/tests/integer_invariance_tests.rs +++ b/iOverlay/tests/integer_invariance_tests.rs @@ -42,8 +42,9 @@ fn boolean_results_ignore_contour_start_direction_and_operand_order() { reversed_b.rotate_left(case % b.len()); for fill in [FillRule::EvenOdd, FillRule::NonZero] { for rule in [OverlayRule::Intersect, OverlayRule::Union, OverlayRule::Xor] { - let expected = canonical(Overlay::with_contour(&a, &b).overlay(rule, fill)); - let actual = canonical(Overlay::with_contour(&reversed_b, &reversed_a).overlay(rule, fill)); + let expected = canonical(Overlay::from_subj_and_clip(&a, &b).overlay(rule, fill)); + let actual = + canonical(Overlay::from_subj_and_clip(&reversed_b, &reversed_a).overlay(rule, fill)); assert_eq!( actual, expected, "case={case}, fill={fill:?}, rule={rule:?}, a={a:?}, b={b:?}" @@ -79,11 +80,11 @@ fn adding_rectangles_after_extraction_matches_a_fresh_overlay() { let b = rectangle(); let c = rectangle(); for solver in [Solver::LIST, Solver::TREE, Solver::FRAG] { - let mut reused = Overlay::with_contour(&a, &b); + let mut reused = Overlay::from_subj_and_clip(&a, &b); reused.solver = solver; reused.overlay(OverlayRule::Intersect, FillRule::EvenOdd); reused.add_contour(&c, ShapeType::Subject); - let mut fresh = Overlay::with_contour(&a, &b); + let mut fresh = Overlay::from_subj_and_clip(&a, &b); fresh.solver = solver; fresh.add_contour(&c, ShapeType::Subject); assert_eq!( diff --git a/iOverlay/tests/integer_range_tests.rs b/iOverlay/tests/integer_range_tests.rs index 35922405..5738f553 100644 --- a/iOverlay/tests/integer_range_tests.rs +++ b/iOverlay/tests/integer_range_tests.rs @@ -45,7 +45,7 @@ fn check + Into>( 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) + let result = Overlay::::from_subj_and_clip_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 { @@ -321,7 +321,7 @@ fn spiral_vector_area_at_coordinate_limits() { ]); 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); + let mut overlay = Overlay::::from_subj_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); @@ -368,7 +368,7 @@ fn fragment_radius_at_coordinate_limits() { }, ..Solver::FRAG }; - let actual = Overlay::::with_contours_custom(&input, &[], Default::default(), solver) + let actual = Overlay::::from_subj_custom(&input, Default::default(), solver) .overlay(OverlayRule::Subject, FillRule::EvenOdd); let actual = actual .iter() @@ -419,14 +419,14 @@ fn seeded_boundary_overlays_match_a_wider_engine() { ]; let rule = rules[case % rules.len()]; for solver in [Solver::LIST, Solver::TREE, Solver::FRAG, Solver::AUTO] { - let wide = Overlay::::with_contours_custom( + let wide = Overlay::::from_subj_and_clip_custom( &[contour::(&subject)], &[contour::(&clip)], Default::default(), solver, ) .overlay(rule, FillRule::EvenOdd); - let narrow = Overlay::::with_contours_custom( + let narrow = Overlay::::from_subj_and_clip_custom( &[contour::(&subject)], &[contour::(&clip)], Default::default(), diff --git a/iOverlay/tests/issue_91_tests.rs b/iOverlay/tests/issue_91_tests.rs index fe49e70c..3ecff254 100644 --- a/iOverlay/tests/issue_91_tests.rs +++ b/iOverlay/tests/issue_91_tests.rs @@ -51,7 +51,7 @@ fn overlay( let x = 100 + 40 * i as i32; contours.push(contour(&[[x, 100], [x + 10, 100], [x + 10, 110], [x, 110]])); } - let mut overlay = Overlay::with_contours(&contours, &[]); + let mut overlay = Overlay::from_subj(&contours); overlay.options.ogc = ogc; overlay.options.preserve_output_collinear = preserve_collinear; overlay.options.output_direction = if clockwise { diff --git a/iOverlay/tests/ocg_tests.rs b/iOverlay/tests/ocg_tests.rs index ccf97443..127a41f5 100644 --- a/iOverlay/tests/ocg_tests.rs +++ b/iOverlay/tests/ocg_tests.rs @@ -37,7 +37,7 @@ mod tests { [[2, 1], [2, 2], [3, 2], [3, 3], [4, 3], [4, 1]], ]; - let mut overlay = Overlay::with_contours_custom( + let mut overlay = Overlay::from_subj_and_clip_custom( &subj_paths, &clip_paths, IntOverlayOptions::ogc(), @@ -82,7 +82,8 @@ mod tests { let mut opts = IntOverlayOptions::ogc(); opts.output_direction = ContourDirection::Clockwise; - let mut overlay = Overlay::with_contours_custom(&subj_paths, &clip_paths, opts, Default::default()); + let mut overlay = + Overlay::from_subj_and_clip_custom(&subj_paths, &clip_paths, opts, Default::default()); let result = overlay.overlay(OverlayRule::Difference, FillRule::EvenOdd); @@ -118,7 +119,7 @@ mod tests { [[3, 2], [3, 3], [4, 3], [4, 2]], ]; - let mut overlay = Overlay::with_contours_custom( + let mut overlay = Overlay::from_subj_and_clip_custom( &subj_paths, &clip_paths, IntOverlayOptions::ogc(), @@ -168,7 +169,7 @@ mod tests { [[5, 3], [5, 4], [6, 4], [6, 3]], ]; - let mut overlay = Overlay::with_contours_custom( + let mut overlay = Overlay::from_subj_and_clip_custom( &subj_paths, &clip_paths, IntOverlayOptions::ogc(), @@ -199,7 +200,7 @@ mod tests { let subj_paths = int_shape![[[0, 3], [0, 0], [3, 0], [3, 2], [1, 2], [1, 1], [2, 1], [2, 3]]]; let mut overlay = - Overlay::with_contours_custom(&subj_paths, &[], IntOverlayOptions::ogc(), Default::default()); + Overlay::from_subj_custom(&subj_paths, IntOverlayOptions::ogc(), Default::default()); let result = overlay.overlay(OverlayRule::Union, FillRule::EvenOdd); @@ -226,7 +227,7 @@ mod tests { let clip_paths = int_shape![[[1, 2], [1, 1], [2, 1], [2, 2]], [[2, 3], [2, 2], [3, 2], [3, 3]],]; - let mut overlay = Overlay::with_contours_custom( + let mut overlay = Overlay::from_subj_and_clip_custom( &subj_paths, &clip_paths, IntOverlayOptions::ogc(), @@ -259,7 +260,7 @@ mod tests { let clip_paths = int_shape![[[1, 2], [1, 1], [2, 1], [2, 2]], [[2, 3], [2, 2], [3, 2], [3, 3]],]; - let mut overlay = Overlay::with_contours_custom( + let mut overlay = Overlay::from_subj_and_clip_custom( &subj_paths, &clip_paths, IntOverlayOptions::ogc(), @@ -295,7 +296,8 @@ mod tests { let mut opts = IntOverlayOptions::ogc(); opts.output_direction = ContourDirection::Clockwise; - let mut overlay = Overlay::with_contours_custom(&subj_paths, &clip_paths, opts, Default::default()); + let mut overlay = + Overlay::from_subj_and_clip_custom(&subj_paths, &clip_paths, opts, Default::default()); let result = overlay.overlay(OverlayRule::Difference, FillRule::EvenOdd); @@ -320,7 +322,7 @@ mod tests { let subj_paths = int_shape![[[0, 3], [0, 0], [5, 0], [5, 3], [3, 3], [3, 2], [2, 2], [2, 3]],]; let clip_paths = int_shape![[[1, 2], [1, 1], [2, 1], [2, 2]], [[3, 2], [3, 1], [4, 1], [4, 2]],]; - let mut overlay = Overlay::with_contours_custom( + let mut overlay = Overlay::from_subj_and_clip_custom( &subj_paths, &clip_paths, IntOverlayOptions::ogc(), @@ -355,7 +357,7 @@ mod tests { ]; let mut overlay = - Overlay::with_contours_custom(&subj_paths, &[], IntOverlayOptions::ogc(), Default::default()); + Overlay::from_subj_custom(&subj_paths, IntOverlayOptions::ogc(), Default::default()); let result = overlay.overlay(OverlayRule::Union, FillRule::EvenOdd); @@ -403,7 +405,7 @@ mod tests { ]]; let clip_paths = int_shape![[[2, 3], [2, 2], [3, 2], [3, 3]]]; - let mut overlay = Overlay::with_contours_custom( + let mut overlay = Overlay::from_subj_and_clip_custom( &subj_paths, &clip_paths, IntOverlayOptions::ogc(), @@ -427,7 +429,7 @@ mod tests { ]; let mut overlay = - Overlay::with_contours_custom(&subj_paths, &[], IntOverlayOptions::ogc(), Default::default()); + Overlay::from_subj_custom(&subj_paths, IntOverlayOptions::ogc(), Default::default()); let result = overlay.overlay(OverlayRule::Union, FillRule::EvenOdd); @@ -450,7 +452,7 @@ mod tests { ]; let mut overlay = - Overlay::with_contours_custom(&subj_paths, &[], IntOverlayOptions::ogc(), Default::default()); + Overlay::from_subj_custom(&subj_paths, IntOverlayOptions::ogc(), Default::default()); let shapes = overlay.overlay(OverlayRule::Union, FillRule::NonZero); assert_eq!(shapes[0].len(), 2); @@ -491,7 +493,7 @@ mod tests { ]; let mut overlay = - Overlay::with_contours_custom(&subj_paths, &[], IntOverlayOptions::ogc(), Default::default()); + Overlay::from_subj_custom(&subj_paths, IntOverlayOptions::ogc(), Default::default()); let shapes = overlay.overlay(OverlayRule::Union, FillRule::NonZero); assert_eq!(shapes.len(), 4); @@ -566,7 +568,7 @@ mod tests { } let mut overlay = - Overlay::with_contours_custom(&subj_paths, &[], IntOverlayOptions::ogc(), Default::default()); + Overlay::from_subj_custom(&subj_paths, IntOverlayOptions::ogc(), Default::default()); let result = overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); @@ -627,7 +629,7 @@ mod tests { } let mut overlay = - Overlay::with_contours_custom(&subj_paths, &[], IntOverlayOptions::ogc(), Default::default()); + Overlay::from_subj_custom(&subj_paths, IntOverlayOptions::ogc(), Default::default()); let result = overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); @@ -682,7 +684,7 @@ mod tests { } } - let mut overlay = Overlay::with_contours_custom( + let mut overlay = Overlay::from_subj_and_clip_custom( &subj_paths, &clip_paths, IntOverlayOptions::ogc(), @@ -766,7 +768,7 @@ mod tests { clip_paths.push(random_star_contour(&mut rng, 680, 60, 220)); } - let mut overlay = Overlay::with_contours_custom( + let mut overlay = Overlay::from_subj_and_clip_custom( &subj_paths, &clip_paths, IntOverlayOptions::ogc(), @@ -775,8 +777,7 @@ mod tests { let result = overlay.overlay(OverlayRule::Difference, FillRule::EvenOdd); - let mut overlay = - Overlay::with_shapes_options(&result, &[], IntOverlayOptions::ogc(), Default::default()); + let mut overlay = Overlay::from_subj_custom(&result, IntOverlayOptions::ogc(), Default::default()); let normalized = overlay.overlay(OverlayRule::Union, FillRule::EvenOdd); let result_area = result.area().abs(); diff --git a/iOverlay/tests/ogc_area_filter_tests.rs b/iOverlay/tests/ogc_area_filter_tests.rs index fea10b5b..688fc337 100644 --- a/iOverlay/tests/ogc_area_filter_tests.rs +++ b/iOverlay/tests/ogc_area_filter_tests.rs @@ -45,7 +45,7 @@ fn ogc_area_filter_matches_filtering_resolved_contours() { .map(|_| (0..3 + case % 8).map(|_| IntPoint::new(next(), next())).collect()) .collect(); for clockwise in [false, true] { - let mut overlay = Overlay::with_contours(&paths, &[]); + let mut overlay = Overlay::from_subj(&paths); overlay.options.ogc = true; overlay.options.output_direction = if clockwise { ContourDirection::Clockwise diff --git a/iOverlay/tests/ogc_hole_orientation_tests.rs b/iOverlay/tests/ogc_hole_orientation_tests.rs index 71e3e0ab..2c925695 100644 --- a/iOverlay/tests/ogc_hole_orientation_tests.rs +++ b/iOverlay/tests/ogc_hole_orientation_tests.rs @@ -22,7 +22,7 @@ fn check_hole_direction(clockwise: bool) { for shift in 0..7 { let mut path = touching_hole(); path.rotate_left(shift); - let mut overlay = Overlay::with_contour(&path, &[]); + let mut overlay = Overlay::from_subj(&path); overlay.options.ogc = true; overlay.options.output_direction = if clockwise { ContourDirection::Clockwise @@ -55,11 +55,11 @@ fn clockwise_ogc_leftmost_touching_hole_has_opposite_winding() { #[test] fn ogc_leftmost_touching_hole_survives_nonzero_roundtrip() { - let mut overlay = Overlay::with_contour(&touching_hole(), &[]); + let mut overlay = Overlay::from_subj(&touching_hole()); overlay.options.ogc = true; let shapes = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); let contours: Vec<_> = shapes.into_iter().flatten().collect(); - let result = Overlay::with_contours(&contours, &[]).overlay(OverlayRule::Subject, FillRule::NonZero); + let result = Overlay::from_subj(&contours).overlay(OverlayRule::Subject, FillRule::NonZero); let area: i64 = result.iter().flatten().map(|p| double_area(p)).sum(); assert_eq!( area, 10, @@ -69,8 +69,7 @@ fn ogc_leftmost_touching_hole_survives_nonzero_roundtrip() { #[test] fn ordinary_extraction_preserves_leftmost_touching_hole_area() { - let shapes = - Overlay::with_contour(&touching_hole(), &[]).overlay(OverlayRule::Subject, FillRule::NonZero); + let shapes = Overlay::from_subj(&touching_hole()).overlay(OverlayRule::Subject, FillRule::NonZero); let area: i64 = shapes.iter().flatten().map(|p| double_area(p)).sum(); assert_eq!(area, 10); } @@ -109,7 +108,7 @@ fn check_shared_leftmost_holes(input: &[Vec], expected: &[Vec], expected: &[Vec = shapes.into_iter().flatten().collect(); - let result = Overlay::with_contours(&contours, &[]).overlay(OverlayRule::Subject, FillRule::NonZero); + let result = Overlay::from_subj(&contours).overlay(OverlayRule::Subject, FillRule::NonZero); let area: i64 = result.iter().flatten().map(|p| double_area(p)).sum(); assert_eq!( area, 184, diff --git a/iOverlay/tests/outline_cell_oracle_tests.rs b/iOverlay/tests/outline_cell_oracle_tests.rs index 16e68ab6..705e7f1d 100644 --- a/iOverlay/tests/outline_cell_oracle_tests.rs +++ b/iOverlay/tests/outline_cell_oracle_tests.rs @@ -44,7 +44,7 @@ fn orthogonal_miter_offsets_match_cell_dilation_and_erosion() { } } } - let mut overlay = Overlay::with_contours(&contours, &[]); + let mut overlay = Overlay::from_subj(&contours); overlay.options.ogc = true; let subject = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); let source: Vec>> = subject diff --git a/iOverlay/tests/outline_grid_area_tests.rs b/iOverlay/tests/outline_grid_area_tests.rs index c61bd47f..ca6331c4 100644 --- a/iOverlay/tests/outline_grid_area_tests.rs +++ b/iOverlay/tests/outline_grid_area_tests.rs @@ -66,7 +66,7 @@ fn outward_outline_expands_half_grid_cell_components() { #[test] fn integer_overlay_preserves_half_grid_cell_contours() { let triangle = vec![IntPoint::new(0, 0), IntPoint::new(1, 0), IntPoint::new(0, 1)]; - let result = Overlay::with_contour(&triangle, &[]).overlay(OverlayRule::Subject, FillRule::Positive); + let result = Overlay::from_subj(&triangle).overlay(OverlayRule::Subject, FillRule::Positive); assert_eq!(result.len(), 1); assert_eq!(result[0][0].len(), 3); let mut hole = triangle; @@ -80,7 +80,7 @@ fn integer_overlay_preserves_half_grid_cell_contours() { ], hole, ]; - let result = Overlay::with_contours(&shape, &[]).overlay(OverlayRule::Subject, FillRule::Positive); + let result = Overlay::from_subj(&shape).overlay(OverlayRule::Subject, FillRule::Positive); assert_eq!(result.len(), 1); assert_eq!(result[0].len(), 2); } diff --git a/iOverlay/tests/outline_identity_tests.rs b/iOverlay/tests/outline_identity_tests.rs index f56b33c3..525bc2c7 100644 --- a/iOverlay/tests/outline_identity_tests.rs +++ b/iOverlay/tests/outline_identity_tests.rs @@ -67,7 +67,7 @@ fn ogc_output_preserves_geometry_through_zero_outline() { .collect() }) .collect(); - let mut overlay = Overlay::with_contours(&paths, &[]); + let mut overlay = Overlay::from_subj(&paths); overlay.options.ogc = true; let source = overlay.overlay(OverlayRule::Subject, FillRule::EvenOdd); for shape in &source { diff --git a/iOverlay/tests/output_consistency_tests.rs b/iOverlay/tests/output_consistency_tests.rs index fa27cdd7..a76e71a2 100644 --- a/iOverlay/tests/output_consistency_tests.rs +++ b/iOverlay/tests/output_consistency_tests.rs @@ -30,7 +30,7 @@ fn flat_and_vector_outputs_match_shapes_with_area_filtering() { let a: Vec<_> = (0..3 + case % 8).map(|_| IntPoint::new(next(), next())).collect(); let b: Vec<_> = (0..3 + case % 9).map(|_| IntPoint::new(next(), next())).collect(); for threshold in [0, 1, 10, 50] { - let mut regular = Overlay::with_contour(&a, &b); + let mut regular = Overlay::from_subj_and_clip(&a, &b); regular.options.min_output_area = threshold; regular.options.preserve_output_collinear = case % 2 == 0; regular.options.output_direction = if case % 3 == 0 { diff --git a/iOverlay/tests/overlay_tests.rs b/iOverlay/tests/overlay_tests.rs index 4f7f99d0..426aff60 100644 --- a/iOverlay/tests/overlay_tests.rs +++ b/iOverlay/tests/overlay_tests.rs @@ -28,7 +28,7 @@ mod tests { }; fn overlay(test: &BooleanTest, options: IntOverlayOptions, solver: Solver) -> Overlay { - Overlay::with_contours_custom(&test.subj_paths, &test.clip_paths, options, solver) + Overlay::from_subj_and_clip_custom(&test.subj_paths, &test.clip_paths, options, solver) } let mut buffer = Default::default(); @@ -96,8 +96,12 @@ mod tests { #[allow(dead_code)] fn debug_execute(index: usize, overlay_rule: OverlayRule, fill_rule: FillRule, solver: Solver) { let test = BooleanTest::load(index); - let mut overlay = - Overlay::with_contours_custom(&test.subj_paths, &test.clip_paths, Default::default(), solver); + let mut overlay = Overlay::from_subj_and_clip_custom( + &test.subj_paths, + &test.clip_paths, + Default::default(), + solver, + ); let graph = overlay.build_graph_view(fill_rule).unwrap(); let result = graph.extract_shapes(overlay_rule, &mut Default::default()); @@ -128,7 +132,7 @@ mod tests { #[allow(dead_code)] fn print_json(index: usize, fill_rule: FillRule) { let test = BooleanTest::load(index); - let mut overlay = Overlay::with_contours(&test.subj_paths, &test.clip_paths); + let mut overlay = Overlay::from_subj_and_clip(&test.subj_paths, &test.clip_paths); let mut buffer = overlay.boolean_buffer.take().unwrap_or_default(); let graph = overlay.build_graph_view(fill_rule).unwrap(); diff --git a/iOverlay/tests/predicate_cell_oracle_tests.rs b/iOverlay/tests/predicate_cell_oracle_tests.rs index 824d58d3..355802c3 100644 --- a/iOverlay/tests/predicate_cell_oracle_tests.rs +++ b/iOverlay/tests/predicate_cell_oracle_tests.rs @@ -68,8 +68,8 @@ fn predicates_match_filled_cells_after_contour_cancellation() { } let mut overlay = PredicateOverlay::new(0); overlay.solver = [Solver::LIST, Solver::TREE, Solver::FRAG][case % 3]; - overlay.add_contours(&contours[0], ShapeType::Subject); - overlay.add_contours(&contours[1], ShapeType::Clip); + overlay.add_source(&contours[0], ShapeType::Subject); + overlay.add_source(&contours[1], ShapeType::Clip); for rule in [ FillRule::EvenOdd, FillRule::NonZero, diff --git a/iOverlay/tests/resolved_geometry_tests.rs b/iOverlay/tests/resolved_geometry_tests.rs index fbb5294a..da028134 100644 --- a/iOverlay/tests/resolved_geometry_tests.rs +++ b/iOverlay/tests/resolved_geometry_tests.rs @@ -48,7 +48,7 @@ fn dense_boolean_output_has_resolved_edges_and_correct_point_locations() { let b: Vec<_> = (0..20 + case % 40) .map(|_| IntPoint::new(next(), next())) .collect(); - let mut overlay = Overlay::with_contour(&a, &b); + let mut overlay = Overlay::from_subj_and_clip(&a, &b); overlay.solver = [Solver::LIST, Solver::TREE, Solver::FRAG][case % 3]; overlay.options.ogc = true; let rule = [ @@ -100,4 +100,3 @@ fn dense_boolean_output_has_resolved_edges_and_correct_point_locations() { } } } - diff --git a/iOverlay/tests/slice_area_tests.rs b/iOverlay/tests/slice_area_tests.rs index f4715fea..6e823c28 100644 --- a/iOverlay/tests/slice_area_tests.rs +++ b/iOverlay/tests/slice_area_tests.rs @@ -24,7 +24,7 @@ fn area_two(path: &[IntPoint]) -> i64 { #[test] fn slice_minimum_area_removes_small_piece() { - let mut overlay = StringOverlay::with_shape_contour(&square()); + let mut overlay = StringOverlay::from_shape(&square()); overlay.add_string_line([IntPoint::new(1, -1), IntPoint::new(1, 11)]); let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); let unfiltered = graph.extract_shapes(StringRule::Slice); @@ -51,7 +51,7 @@ fn slice_minimum_area_removes_small_piece() { #[test] fn slice_minimum_area_above_subject_area_returns_empty() { - let mut overlay = StringOverlay::with_shape_contour(&square()); + let mut overlay = StringOverlay::from_shape(&square()); let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); let mut options = IntOverlayOptions::default(); options.min_output_area = 1000; @@ -64,7 +64,7 @@ fn slice_minimum_area_above_subject_area_returns_empty() { #[test] fn slice_small_area_threshold_preserves_large_loops_and_holes() { - let mut overlay = StringOverlay::with_shape_contour(&square()); + let mut overlay = StringOverlay::from_shape(&square()); overlay.add_string_path(&[ IntPoint::new(0, 0), IntPoint::new(3, 3), diff --git a/iOverlay/tests/slice_partition_tests.rs b/iOverlay/tests/slice_partition_tests.rs index 14671154..c828ca8a 100644 --- a/iOverlay/tests/slice_partition_tests.rs +++ b/iOverlay/tests/slice_partition_tests.rs @@ -50,7 +50,7 @@ fn grid_cuts_partition_subject_without_gaps_or_overlaps() { } } } - let mut overlay = StringOverlay::with_shape_contour(&subject); + let mut overlay = StringOverlay::from_shape(&subject); overlay.add_string_lines(&cuts); let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); let result = graph.extract_shapes(StringRule::Slice); @@ -94,7 +94,7 @@ fn interior_self_crossing_cuts_preserve_subject_area() { if case % 2 == 0 { cut.push(cut[0]); } - let mut overlay = StringOverlay::with_shape_contour(&subject); + let mut overlay = StringOverlay::from_shape(&subject); overlay.add_string_path(&cut); let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); let result = graph.extract_shapes(StringRule::Slice); From 13f131bbd333096e0dda42bde172e3092be128d0 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Fri, 18 Sep 2026 21:29:24 +0300 Subject: [PATCH 35/44] fmt --- iOverlay/src/segm/build.rs | 5 +---- iOverlay/tests/float_conversion_tests.rs | 10 +++------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/iOverlay/src/segm/build.rs b/iOverlay/src/segm/build.rs index 965c7390..bf6d2344 100644 --- a/iOverlay/src/segm/build.rs +++ b/iOverlay/src/segm/build.rs @@ -129,10 +129,7 @@ impl PointFilter for DropCollinear { impl Segment { #[inline] pub(crate) fn with_ab(p0: IntPoint, p1: IntPoint, direct: C, invert: C) -> Self { - debug_assert!( - p0 != p1, - "zero-length edges must be filtered before construction" - ); + debug_assert!(p0 != p1, "zero-length edges must be filtered before construction"); if p0 < p1 { Self { x_segment: XSegment { a: p0, b: p1 }, diff --git a/iOverlay/tests/float_conversion_tests.rs b/iOverlay/tests/float_conversion_tests.rs index 3f9fdb84..8e3437b7 100644 --- a/iOverlay/tests/float_conversion_tests.rs +++ b/iOverlay/tests/float_conversion_tests.rs @@ -5,17 +5,13 @@ use i_overlay::float::overlay::FloatOverlay; fn check_subject_near_scale_boundary(side: f64, grid_step: f64) { let subject = [[0.0, 0.0], [side, side], [0.0, side]]; - let clip = [ - [side - grid_step, side], - [side, side - grid_step], - [side, side], - ]; + let clip = [[side - grid_step, side], [side, side - grid_step], [side, side]]; // Both inputs have the same combined bounds as the subject alone. The // small clip crosses its diagonal near the top-right corner. Extracting // Subject must preserve that triangle, regardless of the clip. - let expected = FloatOverlay::<[f64; 2], I>::from_subj(&subject) - .overlay(OverlayRule::Subject, FillRule::NonZero); + let expected = + FloatOverlay::<[f64; 2], I>::from_subj(&subject).overlay(OverlayRule::Subject, FillRule::NonZero); assert_eq!(expected.len(), 1); let actual = FloatOverlay::<[f64; 2], I>::from_subj_and_clip(&subject, &clip) .overlay(OverlayRule::Subject, FillRule::NonZero); From b28499d685dea557091cab5f763960dfd6b2bece Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Fri, 18 Sep 2026 21:32:41 +0300 Subject: [PATCH 36/44] fix doc --- iOverlay/src/float/relate.rs | 2 +- iOverlay/src/float/simplify.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/iOverlay/src/float/relate.rs b/iOverlay/src/float/relate.rs index 45394cce..dece738e 100644 --- a/iOverlay/src/float/relate.rs +++ b/iOverlay/src/float/relate.rs @@ -10,7 +10,7 @@ use i_shape::source::float::resource::ShapeResource; /// Float-coordinate wrapper for spatial predicate evaluation. /// /// `FloatPredicateOverlay` handles conversion from floating-point coordinates to -/// the internal integer representation, then delegates to [`PredicateOverlay`](crate::core::relate::PredicateOverlay) +/// the internal integer representation, then delegates to [`PredicateOverlay`] /// for efficient predicate evaluation. /// /// # Example diff --git a/iOverlay/src/float/simplify.rs b/iOverlay/src/float/simplify.rs index 026e8866..59f2233f 100644 --- a/iOverlay/src/float/simplify.rs +++ b/iOverlay/src/float/simplify.rs @@ -42,7 +42,7 @@ pub trait SimplifyShape { /// Simplifies the shape or collection of points, contours, or shapes, based on a specified minimum area threshold. /// - `options`: Adjust custom behavior. /// - `solver`: Type of solver to use. - /// - Returns: A collection of Shapes

that represents the simplified geometry. + /// - Returns: A collection of `Shapes

` that represents the simplified geometry. /// /// Note: Outer boundary paths have a **main_direction** order, and holes have an opposite to **main_direction** order. fn simplify_shape_custom( From aee082b7b273a4913529efa3060dd839ac9866ae Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Fri, 18 Sep 2026 21:57:13 +0300 Subject: [PATCH 37/44] fix empty buffer --- iOverlay/src/vector/simplify.rs | 6 ++-- iOverlay/tests/edge_overlay_tests.rs | 46 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/iOverlay/src/vector/simplify.rs b/iOverlay/src/vector/simplify.rs index 4e47ab8b..521eabad 100644 --- a/iOverlay/src/vector/simplify.rs +++ b/iOverlay/src/vector/simplify.rs @@ -4,7 +4,6 @@ use alloc::vec; use alloc::vec::Vec; use i_float::int::number::int::IntNumber; use i_float::int::number::wide_int::WideIntNumber; -use i_float::int::point::IntPoint; use i_float::int::vector::IntVector; /// Simplifies vector contours by removing collinear points when possible. @@ -205,7 +204,7 @@ impl VectorSimpleContour for [DataVectorEdge(edge: &DataVectorEdge) -> IntVector { mod tests { use crate::core::edge_data::{EdgeDataMerge, OverlayEdgeData}; use crate::vector::edge::DataVectorEdge; - use crate::vector::simplify::{IntPoint, VectorSimplify}; + use crate::vector::simplify::VectorSimplify; use alloc::vec; + use i_float::int::point::IntPoint; use i_float::int_pnt; #[derive(Clone, Copy, PartialEq)] diff --git a/iOverlay/tests/edge_overlay_tests.rs b/iOverlay/tests/edge_overlay_tests.rs index cb937434..d79b2410 100644 --- a/iOverlay/tests/edge_overlay_tests.rs +++ b/iOverlay/tests/edge_overlay_tests.rs @@ -10,6 +10,52 @@ mod tests { use i_overlay::segm::boolean::ShapeCountBoolean; use i_overlay::vector::edge::DataVectorEdge; + #[test] + fn simplifying_collinear_edges_preserves_data_store() { + #[derive(Clone, Copy, PartialEq, Eq)] + struct StoredData(usize); + + impl OverlayEdgeData for StoredData { + // Maps each data index to its reversed counterpart. + type Store = Vec; + + fn reversed(self, store: &mut Self::Store) -> Self { + Self(store[self.0]) + } + + fn merge(ctx: EdgeDataMerge, _: &mut Self::Store) -> Self { + ctx.lhs_data + } + } + + // A rectangle with one redundant vertex on its bottom edge. + let path = [ + int_pnt!(0, 0), + int_pnt!(5, 0), + int_pnt!(10, 0), + int_pnt!(10, 10), + int_pnt!(0, 10), + ]; + let mut overlay = EdgeOverlay::::new(path.len()); + overlay.data_store_mut().push(0); + overlay.options.preserve_output_collinear = false; + for i in 0..path.len() { + overlay.add_edge( + InputEdge { + a: path[i], + b: path[(i + 1) % path.len()], + data: StoredData(0), + }, + ShapeType::Subject, + ); + } + + let shapes = overlay.build_vector_shapes(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(shapes.len(), 1); + assert_eq!(shapes[0].len(), 1); + assert_eq!(shapes[0][0].len(), 4); + } + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Color { Red, From 865476f93f5b414a5282a62166b788e21671c275 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Fri, 18 Sep 2026 22:04:36 +0300 Subject: [PATCH 38/44] fix vector init --- iOverlay/src/vector/simplify.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/iOverlay/src/vector/simplify.rs b/iOverlay/src/vector/simplify.rs index 521eabad..105c4ba6 100644 --- a/iOverlay/src/vector/simplify.rs +++ b/iOverlay/src/vector/simplify.rs @@ -204,17 +204,19 @@ impl VectorSimpleContour for [DataVectorEdge Date: Fri, 18 Sep 2026 22:09:09 +0300 Subject: [PATCH 39/44] fix edge merge --- iOverlay/src/vector/simplify.rs | 108 +++++++++++++++++++------------- iOverlay/tests/vector_tests.rs | 44 ++++++++++++- 2 files changed, 107 insertions(+), 45 deletions(-) diff --git a/iOverlay/src/vector/simplify.rs b/iOverlay/src/vector/simplify.rs index 105c4ba6..10ccd0d1 100644 --- a/iOverlay/src/vector/simplify.rs +++ b/iOverlay/src/vector/simplify.rs @@ -115,7 +115,10 @@ impl VectorSimpleContour for [DataVectorEdge VectorSimpleContour for [DataVectorEdge = shapes[0][0] + .iter() + .filter(|edge| edge.a.y == 0 && edge.b.y == 0) + .map(|edge| (edge.a.x, edge.b.x, edge.fill)) + .collect(); + assert_eq!( + bottom, + vec![(0, 5, SUBJ_LEFT), (5, 10, SUBJ_LEFT | CLIP_LEFT), (10, 15, CLIP_LEFT)], + "redundant_vertex={redundant_vertex}" + ); + assert_eq!(shapes[0][0].len(), 8); + } + } #[test] fn test_0() { From 4cd672a94f04cdc47c90b6f1d1554b26d0ec9144 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Sat, 19 Sep 2026 11:49:39 +0300 Subject: [PATCH 40/44] move to new contarct --- iOverlay/README.md | 20 +++- iOverlay/src/core/integer.rs | 4 +- iOverlay/src/float/adapter.rs | 33 ++++++ iOverlay/src/float/hierarchy.rs | 18 ++- iOverlay/src/float/mod.rs | 11 ++ iOverlay/src/float/overlay.rs | 13 ++- iOverlay/src/float/relate.rs | 5 +- iOverlay/src/float/scale.rs | 29 ++++- iOverlay/src/float/string_overlay.rs | 10 +- iOverlay/src/lib.rs | 6 + iOverlay/src/mesh/float/outline/offset.rs | 30 ++--- iOverlay/src/mesh/float/stroke/offset.rs | 30 +++-- .../src/mesh/float/variable_stroke/offset.rs | 37 +++--- iOverlay/src/vector/simplify.rs | 1 - .../tests/float_coordinate_contract_tests.rs | 108 ++++++++++++++++++ iOverlay/tests/float_point_adapter.rs | 13 ++- iOverlay/tests/integer_range_tests.rs | 4 +- iOverlay/tests/mesh_scale_tests.rs | 12 +- iOverlay/tests/output_consistency_tests.rs | 2 +- 19 files changed, 305 insertions(+), 81 deletions(-) create mode 100644 iOverlay/src/float/adapter.rs create mode 100644 iOverlay/tests/float_coordinate_contract_tests.rs diff --git a/iOverlay/README.md b/iOverlay/README.md index 090c4d19..6fa52a51 100644 --- a/iOverlay/README.md +++ b/iOverlay/README.md @@ -38,6 +38,7 @@ For specialized geometry, see [iCurve](https://github.com/iShape-Rust/iCurve) fo - [LineCap](#linecap) - [LineJoin](#linejoin) - [Integer Coordinate Limits](#integer-coordinate-limits) +- [Floating-Point Coordinate Limits](#floating-point-coordinate-limits) - [FAQ](#faq) - [License](#license) @@ -636,8 +637,23 @@ 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)`. +For float APIs, the limits apply after conversion. Automatic conversion and checked +fixed-scale methods use a conservative budget of `I::BITS - 3` coordinate bits. +Custom adapters must respect the integer range; use +`FloatPointAdapter::with_coordinate_bits(rect, I::BITS - 3)` for the same budget. + +## Floating-Point Coordinate Limits + +Input coordinates must be finite, with absolute values at most `2^60` for `f32` +or `2^500` for `f64`. Stroke and outline bounds, including padding for widths, +offsets, joins, and caps, must also fit these limits. + +Infallible APIs panic on invalid bounds. Fixed-scale APIs return +`FixedScaleOverlayError::InvalidRect`. Scales must be positive and finite, have a +finite reciprocal in the input scalar type, and fit the coordinate budget. +A scale whose reciprocal overflows returns `ScaleTooSmall`; one exceeding the +budget returns `ScaleTooLarge`. Coordinate limits do not themselves limit scales. +Empty valid inputs remain supported. ## FAQ ### 1. When should I use `FloatOverlay`, `SingleFloatOverlay`, or `FloatOverlayGraph`? diff --git a/iOverlay/src/core/integer.rs b/iOverlay/src/core/integer.rs index 44027701..1de4b187 100644 --- a/iOverlay/src/core/integer.rs +++ b/iOverlay/src/core/integer.rs @@ -20,7 +20,9 @@ //! ## Floating-point conversion //! //! These limits concern the integer coordinates after conversion, not the -//! original floating-point coordinates. For an explicit conservative bound, +//! original floating-point coordinates, which have their own [limits](crate::float). +//! Automatic float APIs and checked fixed-scale APIs use `I::BITS - 3` coordinate +//! bits. For a custom adapter with the same 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 diff --git a/iOverlay/src/float/adapter.rs b/iOverlay/src/float/adapter.rs new file mode 100644 index 00000000..4610662c --- /dev/null +++ b/iOverlay/src/float/adapter.rs @@ -0,0 +1,33 @@ +use super::scale::FixedScaleOverlayError; +use crate::core::integer::OverlayInt; +use i_float::adapter::FloatPointAdapter; +use i_float::float::{compatible::FloatPointCompatible, rect::FloatRect}; + +// Reserve enough bits for the engine's coordinate differences and products, +// including rounding at the bounds. +pub(crate) fn adapter_with_iter<'a, P, I>(iter: impl Iterator) -> FloatPointAdapter +where + P: FloatPointCompatible + 'a, + I: OverlayInt, +{ + let rect = FloatRect::with_iter(iter) + .expect("Invalid overlay bounds") + .unwrap_or(FloatRect::zero()); + FloatPointAdapter::with_coordinate_bits(rect, I::BITS - 3) +} + +pub(crate) fn adapter_with_iter_and_scale<'a, P, I>( + iter: impl Iterator, + scale: P::Scalar, +) -> Result, FixedScaleOverlayError> +where + P: FloatPointCompatible + 'a, + I: OverlayInt, +{ + let rect = FloatRect::with_iter(iter)?.unwrap_or(FloatRect::zero()); + Ok(FloatPointAdapter::try_with_scale_and_coordinate_bits( + rect, + scale, + I::BITS - 3, + )?) +} diff --git a/iOverlay/src/float/hierarchy.rs b/iOverlay/src/float/hierarchy.rs index 964a70ea..94d0587c 100644 --- a/iOverlay/src/float/hierarchy.rs +++ b/iOverlay/src/float/hierarchy.rs @@ -232,8 +232,10 @@ mod tests { }, links: vec![], }; - let adapter = - FloatPointAdapter::<[f64; 2], i32>::with_scale(FloatRect::new(-10.0, 20.0, -10.0, 20.0), 1.0); + let adapter = FloatPointAdapter::<[f64; 2], i32>::with_scale( + FloatRect::new(-10.0, 20.0, -10.0, 20.0).unwrap(), + 1.0, + ); let hierarchy = FloatFlatShapeHierarchy::from_int(int_hierarchy, &adapter, true, true); @@ -284,8 +286,10 @@ mod tests { }, ], }; - let adapter = - FloatPointAdapter::<[f64; 2], i32>::with_scale(FloatRect::new(-10.0, 30.0, -10.0, 30.0), 1.0); + let adapter = FloatPointAdapter::<[f64; 2], i32>::with_scale( + FloatRect::new(-10.0, 30.0, -10.0, 30.0).unwrap(), + 1.0, + ); let hierarchy = FloatFlatShapeHierarchy::from_int(int_hierarchy, &adapter, true, false); @@ -326,8 +330,10 @@ mod tests { child_shape_index: 1, }], }; - let adapter = - FloatPointAdapter::<[f64; 2], i32>::with_scale(FloatRect::new(-10.0, 20.0, -10.0, 20.0), 1.0); + let adapter = FloatPointAdapter::<[f64; 2], i32>::with_scale( + FloatRect::new(-10.0, 20.0, -10.0, 20.0).unwrap(), + 1.0, + ); let hierarchy = FloatFlatShapeHierarchy::from_int(int_hierarchy, &adapter, true, false); diff --git a/iOverlay/src/float/mod.rs b/iOverlay/src/float/mod.rs index 5d81ace5..a160466e 100644 --- a/iOverlay/src/float/mod.rs +++ b/iOverlay/src/float/mod.rs @@ -1,3 +1,14 @@ +//! Floating-point inputs must be finite, with absolute coordinates at most +//! `2^60` for `f32` or `2^500` for `f64`. Stroke and outline bounds, including +//! their padding, must also fit. Infallible APIs panic on invalid bounds; +//! fixed-scale APIs return an error. Empty valid input remains supported. +//! +//! Automatic conversion uses `I::BITS - 3` coordinate bits. Fixed-scale APIs +//! enforce the same budget and require a positive finite scale with a finite +//! reciprocal in the input scalar type. Custom adapters remain the caller's +//! responsibility; see the integer coordinate contract in [`crate::core::integer`]. + +pub(crate) mod adapter; pub mod clip; pub mod graph; pub mod hierarchy; diff --git a/iOverlay/src/float/overlay.rs b/iOverlay/src/float/overlay.rs index 8c55fba6..4102d293 100644 --- a/iOverlay/src/float/overlay.rs +++ b/iOverlay/src/float/overlay.rs @@ -7,6 +7,7 @@ use crate::core::integer::OverlayInt; use crate::core::overlay::{ContourDirection, IntOverlayOptions, Overlay, ShapeType}; use crate::core::overlay_rule::OverlayRule; use crate::core::solver::Solver; +use crate::float::adapter::adapter_with_iter; use crate::float::graph::FloatOverlayGraph; use crate::float::hierarchy::FloatFlatShapeHierarchy; use crate::i_shape::source::float::resource::ShapeResource; @@ -150,7 +151,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = FloatPointAdapter::with_iter(iter); + let adapter = adapter_with_iter(iter); let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); @@ -179,7 +180,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = FloatPointAdapter::with_iter(iter); + let adapter = adapter_with_iter(iter); let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); @@ -199,7 +200,7 @@ where R: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().flatten(); - let adapter = FloatPointAdapter::with_iter(iter); + let adapter = adapter_with_iter(iter); let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); Self::with_adapter(adapter, subj_capacity).unsafe_add_source(subj, ShapeType::Subject) @@ -218,7 +219,7 @@ where R: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().flatten(); - let adapter = FloatPointAdapter::with_iter(iter); + let adapter = adapter_with_iter(iter); let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); Self::new_custom(adapter, options, solver, subj_capacity).unsafe_add_source(subj, ShapeType::Subject) @@ -299,7 +300,7 @@ where self.clear(); let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - self.update_adapter(FloatPointAdapter::with_iter(iter)); + self.update_adapter(adapter_with_iter(iter)); self.add_source(subj, ShapeType::Subject); self.add_source(clip, ShapeType::Clip); } @@ -323,7 +324,7 @@ where { self.clear(); let iter = subj.iter_paths().flatten(); - self.update_adapter(FloatPointAdapter::with_iter(iter)); + self.update_adapter(adapter_with_iter(iter)); self.add_source(subj, ShapeType::Subject); } diff --git a/iOverlay/src/float/relate.rs b/iOverlay/src/float/relate.rs index dece738e..ea94856e 100644 --- a/iOverlay/src/float/relate.rs +++ b/iOverlay/src/float/relate.rs @@ -3,6 +3,7 @@ use crate::core::integer::OverlayInt; use crate::core::overlay::ShapeType; use crate::core::relate::PredicateOverlay; use crate::core::solver::Solver; +use crate::float::adapter::adapter_with_iter; use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; use i_shape::source::float::resource::ShapeResource; @@ -81,7 +82,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = FloatPointAdapter::<_, I>::with_iter(iter); + let adapter = adapter_with_iter::<_, I>(iter); let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); @@ -106,7 +107,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = FloatPointAdapter::<_, I>::with_iter(iter); + let adapter = adapter_with_iter::<_, I>(iter); let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); diff --git a/iOverlay/src/float/scale.rs b/iOverlay/src/float/scale.rs index bd46cc51..a0196858 100644 --- a/iOverlay/src/float/scale.rs +++ b/iOverlay/src/float/scale.rs @@ -1,3 +1,4 @@ +use super::adapter::adapter_with_iter_and_scale; use crate::core::fill_rule::FillRule; use crate::core::integer::OverlayInt; use crate::core::overlay::ShapeType; @@ -5,16 +6,21 @@ use crate::core::overlay_rule::OverlayRule; use crate::core::solver::Solver; use crate::float::overlay::{FloatOverlay, OverlayOptions}; use crate::float::relate::FloatPredicateOverlay; -use i_float::adapter::{FloatPointAdapter, FloatPointAdapterScaleError}; +use i_float::adapter::FloatPointAdapterScaleError; use i_float::float::compatible::FloatPointCompatible; use i_float::float::number::FloatNumber; +use i_float::float::rect::FloatRectError; use i_shape::base::data::Shapes; use i_shape::source::float::resource::ShapeResource; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FixedScaleOverlayError { + /// Input or padded bounds violate the floating-point coordinate contract. + InvalidRect(FloatRectError), /// Requested scale is larger than the safe adapter scale for the input bounds. ScaleTooLarge, + /// Scale has a non-finite reciprocal in the input scalar type. + ScaleTooSmall, /// Requested scale is zero or negative. ScaleNonPositive, /// Requested scale is NaN or infinite. @@ -31,6 +37,9 @@ impl FixedScaleOverlayError { if s <= 0.0 { return Err(Self::ScaleNonPositive); } + if !(T::ONE / scale).is_finite() { + return Err(Self::ScaleTooSmall); + } Ok(s) } } @@ -39,6 +48,8 @@ impl From for FixedScaleOverlayError { #[inline] fn from(error: FloatPointAdapterScaleError) -> Self { match error { + FloatPointAdapterScaleError::InvalidRect(error) => Self::InvalidRect(error), + FloatPointAdapterScaleError::ScaleTooSmall => Self::ScaleTooSmall, FloatPointAdapterScaleError::ScaleTooLarge => Self::ScaleTooLarge, FloatPointAdapterScaleError::ScaleNonPositive => Self::ScaleNonPositive, FloatPointAdapterScaleError::ScaleNotFinite => Self::ScaleNotFinite, @@ -46,6 +57,12 @@ impl From for FixedScaleOverlayError { } } +impl From for FixedScaleOverlayError { + fn from(error: FloatRectError) -> Self { + Self::InvalidRect(error) + } +} + /// Trait `FixedScaleFloatOverlay` provides methods for overlay operations between various geometric entities. /// This trait supports boolean operations on contours, shapes, and collections of shapes, using customizable overlay and build rules. /// @@ -177,7 +194,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = FloatPointAdapter::with_iter_and_scale_checked(iter, scale)?; + let adapter = adapter_with_iter_and_scale(iter, scale)?; let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); @@ -213,7 +230,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = FloatPointAdapter::with_iter_and_scale_checked(iter, scale)?; + let adapter = adapter_with_iter_and_scale(iter, scale)?; let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); @@ -286,7 +303,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = FloatPointAdapter::with_iter_and_scale_checked(iter, scale)?; + let adapter = adapter_with_iter_and_scale(iter, scale)?; let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); @@ -318,7 +335,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = FloatPointAdapter::with_iter_and_scale_checked(iter, scale)?; + let adapter = adapter_with_iter_and_scale(iter, scale)?; let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); diff --git a/iOverlay/src/float/string_overlay.rs b/iOverlay/src/float/string_overlay.rs index ce363432..e14d16bf 100644 --- a/iOverlay/src/float/string_overlay.rs +++ b/iOverlay/src/float/string_overlay.rs @@ -1,6 +1,7 @@ use crate::core::fill_rule::FillRule; use crate::core::integer::OverlayInt; use crate::core::solver::Solver; +use crate::float::adapter::{adapter_with_iter, adapter_with_iter_and_scale}; use crate::float::scale::FixedScaleOverlayError; use crate::float::string_graph::FloatStringGraph; use crate::string::clip::ClipRule; @@ -32,8 +33,9 @@ where /// /// - `adapter`: A `FloatPointAdapter` instance responsible for coordinate conversion between /// float and integer values, ensuring accuracy during geometric transformations. - /// Use `FloatPointAdapter::with_scale` to set a fixed scale, or `FloatPointAdapter::new` - /// for automatic scaling based on bounds. + /// Use `FloatPointAdapter::with_scale` to set a fixed scale, or + /// `FloatPointAdapter::with_coordinate_bits(rect, I::BITS - 3)` for automatic + /// scaling. Custom scales must respect the integer coordinate range. /// - `capacity`: Initial capacity for storing segments, ideally matching the total number of /// segments for efficient memory allocation. #[inline] @@ -63,7 +65,7 @@ where R1: ShapeResource

, { let iter = shape.iter_paths().chain(string.iter_paths()).flatten(); - let adapter = FloatPointAdapter::with_iter(iter); + let adapter = adapter_with_iter(iter); let shape_capacity = shape.iter_paths().fold(0, |s, c| s + c.len()); let string_capacity = string.iter_paths().fold(0, |s, c| s + c.len()); @@ -86,7 +88,7 @@ where R1: ShapeResource

, { let iter = shape.iter_paths().chain(string.iter_paths()).flatten(); - let adapter = FloatPointAdapter::with_iter_and_scale_checked(iter, scale)?; + let adapter = adapter_with_iter_and_scale(iter, scale)?; let shape_capacity = shape.iter_paths().fold(0, |s, c| s + c.len()); let string_capacity = string.iter_paths().fold(0, |s, c| s + c.len()); diff --git a/iOverlay/src/lib.rs b/iOverlay/src/lib.rs index 6db3f922..bd41fe15 100644 --- a/iOverlay/src/lib.rs +++ b/iOverlay/src/lib.rs @@ -16,6 +16,12 @@ //! 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). //! +//! ## Floating-point coordinate limits +//! +//! Input coordinates must be finite, with absolute values at most `2^60` for +//! `f32` or `2^500` for `f64`. Stroke and outline padding must fit these bounds +//! too. See the [floating-point contract](float) for errors and scale limits. +//! //! ## 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/mesh/float/outline/offset.rs b/iOverlay/src/mesh/float/outline/offset.rs index 64526259..0834ce59 100644 --- a/iOverlay/src/mesh/float/outline/offset.rs +++ b/iOverlay/src/mesh/float/outline/offset.rs @@ -10,7 +10,7 @@ use alloc::vec; use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; use i_float::float::number::FloatNumber; -use i_float::float::rect::FloatRect; +use i_float::float::rect::{FloatRect, FloatRectError}; use i_float::int::number::int::IntNumber; use i_shape::base::data::Shapes; use i_shape::flat::buffer::FlatContoursBuffer; @@ -293,7 +293,7 @@ where where I: OverlayInt + 'static, { - if let Some(solver) = OutlineSolver::::prepare(self, style) { + if let Some(solver) = OutlineSolver::::prepare(self, style).expect("Invalid offset bounds") { solver.build(self, options) } else { vec![] @@ -308,7 +308,7 @@ where ) where I: OverlayInt + 'static, { - if let Some(solver) = OutlineSolver::::prepare(self, style) { + if let Some(solver) = OutlineSolver::::prepare(self, style).expect("Invalid offset bounds") { solver.build_into(self, options, output) } else { output.clear_and_reserve(0, 0) @@ -348,7 +348,7 @@ where I: OverlayInt + 'static, { let s = FixedScaleOverlayError::validate_scale(scale)?; - let mut solver = match OutlineSolver::::prepare(self, style) { + let mut solver = match OutlineSolver::::prepare(self, style)? { Some(solver) => solver, None => return Ok(vec![]), }; @@ -367,7 +367,7 @@ where I: OverlayInt + 'static, { let s = FixedScaleOverlayError::validate_scale(scale)?; - let mut solver = match OutlineSolver::::prepare(self, style) { + let mut solver = match OutlineSolver::::prepare(self, style)? { Some(solver) => solver, None => { output.clear_and_reserve(0, 0); @@ -390,21 +390,24 @@ where P: FloatPointCompatible + 'static, I: OverlayInt + 'static, { - fn prepare>(source: &S, style: &OutlineStyle) -> Option { + fn prepare>( + source: &S, + style: &OutlineStyle, + ) -> Result, FloatRectError> { if source.iter_paths().next().is_none() { - return None; + return Ok(None); } let additional_offset = P::Scalar::from_float( (style.outer_offset.to_f64().abs() + style.inner_offset.to_f64().abs()) * style.join.padding_factor(), ); - let mut rect = FloatRect::with_iter(source.iter_paths().flatten()).unwrap_or(FloatRect::zero()); - rect.add_offset(additional_offset); + let mut rect = FloatRect::with_iter(source.iter_paths().flatten())?.unwrap_or(FloatRect::zero()); + rect.add_offset(additional_offset)?; - let adapter = FloatPointAdapter::::new(rect); + let adapter = FloatPointAdapter::::with_coordinate_bits(rect, I::BITS - 3); - Some(Self { + Ok(Some(Self { style: OutlineStyle { outer_offset: style.outer_offset, inner_offset: style.inner_offset, @@ -412,12 +415,13 @@ where math: style.math, }, adapter, - }) + })) } fn apply_scale(&mut self, scale: f64) -> Result<(), FixedScaleOverlayError> { let s = P::Scalar::from_float(scale); - self.adapter = FloatPointAdapter::try_with_scale(*self.adapter.rect(), s)?; + self.adapter = + FloatPointAdapter::try_with_scale_and_coordinate_bits(*self.adapter.rect(), s, I::BITS - 3)?; Ok(()) } diff --git a/iOverlay/src/mesh/float/stroke/offset.rs b/iOverlay/src/mesh/float/stroke/offset.rs index ac042c1c..bb463f1d 100644 --- a/iOverlay/src/mesh/float/stroke/offset.rs +++ b/iOverlay/src/mesh/float/stroke/offset.rs @@ -9,7 +9,7 @@ use alloc::vec; use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; use i_float::float::number::FloatNumber; -use i_float::float::rect::FloatRect; +use i_float::float::rect::{FloatRect, FloatRectError}; use i_float::int::number::int::IntNumber; use i_float::int::number::uint::UIntNumber; use i_float::int::number::wide_int::WideIntNumber; @@ -343,7 +343,7 @@ where where I: OverlayInt + 'static, { - match StrokeSolver::::prepare(self, style) { + match StrokeSolver::::prepare(self, style).expect("Invalid offset bounds") { Some(solver) => solver.build(self, is_closed_path, options), None => vec![], } @@ -358,7 +358,7 @@ where ) where I: OverlayInt + 'static, { - match StrokeSolver::::prepare(self, style) { + match StrokeSolver::::prepare(self, style).expect("Invalid offset bounds") { Some(solver) => solver.build_into(self, is_closed_path, options, output), None => output.clear_and_reserve(0, 0), } @@ -399,7 +399,8 @@ where where I: OverlayInt + 'static, { - let mut solver = match StrokeSolver::::prepare(self, style) { + FixedScaleOverlayError::validate_scale(scale)?; + let mut solver = match StrokeSolver::::prepare(self, style)? { Some(solver) => solver, None => return Ok(vec![]), }; @@ -418,7 +419,8 @@ where where I: OverlayInt + 'static, { - let mut solver = match StrokeSolver::::prepare(self, style) { + FixedScaleOverlayError::validate_scale(scale)?; + let mut solver = match StrokeSolver::::prepare(self, style)? { Some(solver) => solver, None => { output.clear_and_reserve(0, 0); @@ -442,23 +444,27 @@ where P: 'static + FloatPointCompatible, I: OverlayInt + 'static, { - fn prepare>(source: &S, style: StrokeStyle

) -> Option { + fn prepare>( + source: &S, + style: StrokeStyle

, + ) -> Result, FloatRectError> { if source.iter_paths().next().is_none() { - return None; + return Ok(None); } let r = P::Scalar::from_float(0.5 * style.width.to_f64()); let a = style.padding(); - let mut rect = FloatRect::with_iter(source.iter_paths().flatten()).unwrap_or(FloatRect::zero()); - rect.add_offset(a); - let adapter = FloatPointAdapter::::new(rect); + let mut rect = FloatRect::with_iter(source.iter_paths().flatten())?.unwrap_or(FloatRect::zero()); + rect.add_offset(a)?; + let adapter = FloatPointAdapter::::with_coordinate_bits(rect, I::BITS - 3); - Some(Self { r, style, adapter }) + Ok(Some(Self { r, style, adapter })) } fn apply_scale(&mut self, scale: P::Scalar) -> Result<(), FixedScaleOverlayError> { - self.adapter = FloatPointAdapter::try_with_scale(*self.adapter.rect(), scale)?; + self.adapter = + FloatPointAdapter::try_with_scale_and_coordinate_bits(*self.adapter.rect(), scale, I::BITS - 3)?; Ok(()) } diff --git a/iOverlay/src/mesh/float/variable_stroke/offset.rs b/iOverlay/src/mesh/float/variable_stroke/offset.rs index 75224611..c4d65fd4 100644 --- a/iOverlay/src/mesh/float/variable_stroke/offset.rs +++ b/iOverlay/src/mesh/float/variable_stroke/offset.rs @@ -11,7 +11,7 @@ use alloc::vec; use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; use i_float::float::number::FloatNumber; -use i_float::float::rect::FloatRect; +use i_float::float::rect::{FloatRect, FloatRectError}; use i_float::int::number::int::IntNumber; use i_float::int::number::uint::UIntNumber; use i_float::int::number::wide_int::WideIntNumber; @@ -120,7 +120,7 @@ where where I: OverlayInt + 'static, { - match VariableStrokeSolver::::prepare(self, style) { + match VariableStrokeSolver::::prepare(self, style).expect("Invalid offset bounds") { Some(solver) => solver.build(self, options), None => vec![], } @@ -134,7 +134,7 @@ where ) where I: OverlayInt + 'static, { - match VariableStrokeSolver::::prepare(self, style) { + match VariableStrokeSolver::::prepare(self, style).expect("Invalid offset bounds") { Some(solver) => solver.build_into(self, options, output), None => output.clear_and_reserve(0, 0), } @@ -172,7 +172,8 @@ where where I: OverlayInt + 'static, { - let mut solver = match VariableStrokeSolver::::prepare(self, style) { + FixedScaleOverlayError::validate_scale(scale)?; + let mut solver = match VariableStrokeSolver::::prepare(self, style)? { Some(solver) => solver, None => return Ok(vec![]), }; @@ -190,7 +191,8 @@ where where I: OverlayInt + 'static, { - let mut solver = match VariableStrokeSolver::::prepare(self, style) { + FixedScaleOverlayError::validate_scale(scale)?; + let mut solver = match VariableStrokeSolver::::prepare(self, style)? { Some(solver) => solver, None => { output.clear_and_reserve(0, 0); @@ -218,7 +220,7 @@ where P: FloatPointCompatible + 'static, { fn variable_stroke_debug(&self, style: VariableStrokeStyle) -> VariableStrokeDebugResult

{ - match VariableStrokeSolver::::prepare(self, style) { + match VariableStrokeSolver::::prepare(self, style).expect("Invalid offset bounds") { Some(solver) => solver.build_debug(self, Default::default()), None => VariableStrokeDebugResult { edges: vec![], @@ -250,7 +252,7 @@ where fn prepare + ?Sized>( source: &S, style: VariableStrokeStyle, - ) -> Option { + ) -> Result, FloatRectError> { let mut max_radius = P::Scalar::ZERO; let mut paths_count = 0; let mut points_count = 0; @@ -265,31 +267,34 @@ where for vertex in path { max_radius = max_radius.max(vertex.radius()); if let Some(rect) = rect.as_mut() { - rect.add_point(&vertex.point); + rect.add_point(&vertex.point)?; } else { - rect = Some(FloatRect::with_point(vertex.point)); + rect = Some(FloatRect::with_point(vertex.point)?); } } } if paths_count == 0 || points_count < 2 || max_radius <= P::Scalar::ZERO { - return None; + return Ok(None); } let style = style.normalized(); - let mut rect = rect?; - rect.add_offset(P::Scalar::from_float(1.1) * max_radius); - let adapter = FloatPointAdapter::::new(rect); + let Some(mut rect) = rect else { + return Ok(None); + }; + rect.add_offset(P::Scalar::from_float(1.1) * max_radius)?; + let adapter = FloatPointAdapter::::with_coordinate_bits(rect, I::BITS - 3); - Some(Self { + Ok(Some(Self { max_radius, style, adapter, - }) + })) } fn apply_scale(&mut self, scale: P::Scalar) -> Result<(), FixedScaleOverlayError> { - self.adapter = FloatPointAdapter::try_with_scale(*self.adapter.rect(), scale)?; + self.adapter = + FloatPointAdapter::try_with_scale_and_coordinate_bits(*self.adapter.rect(), scale, I::BITS - 3)?; Ok(()) } diff --git a/iOverlay/src/vector/simplify.rs b/iOverlay/src/vector/simplify.rs index 10ccd0d1..15b1f720 100644 --- a/iOverlay/src/vector/simplify.rs +++ b/iOverlay/src/vector/simplify.rs @@ -272,7 +272,6 @@ mod tests { use crate::vector::edge::DataVectorEdge; use crate::vector::simplify::VectorSimplify; use alloc::vec; - use i_float::int::point::IntPoint; use i_float::int_pnt; #[derive(Clone, Copy, PartialEq)] diff --git a/iOverlay/tests/float_coordinate_contract_tests.rs b/iOverlay/tests/float_coordinate_contract_tests.rs new file mode 100644 index 00000000..2b0e6aca --- /dev/null +++ b/iOverlay/tests/float_coordinate_contract_tests.rs @@ -0,0 +1,108 @@ +use i_float::float::{number::FloatNumber, rect::FloatRectError}; +use i_overlay::core::{fill_rule::FillRule, overlay_rule::OverlayRule}; +use i_overlay::float::{overlay::FloatOverlay, relate::FloatPredicateOverlay, scale::FixedScaleOverlayError}; +use i_overlay::mesh::float::{ + outline::offset::OutlineOffset, + stroke::offset::StrokeOffset, + style::{OutlineStyle, StrokeStyle}, + variable_stroke::{StrokeVertex, VariableStrokeStyle, offset::VariableStrokeOffset}, +}; +use i_shape::flat::float::FloatFlatContoursBuffer; + +macro_rules! coordinate_contract { + ($name:ident, $scalar:ty) => { + #[test] + fn $name() { + let limit = <$scalar as FloatNumber>::MAX_COORDINATE; + let square = [[-limit, -limit], [limit, -limit], [limit, limit], [-limit, limit]]; + let mut overlay = FloatOverlay::with_subj(&square); + assert_eq!(overlay.overlay(OverlayRule::Subject, FillRule::NonZero).len(), 1); + assert!(FloatOverlay::with_subj_and_clip_fixed_scale(&square, &square, 1.0 / limit).is_ok()); + + let error = FixedScaleOverlayError::InvalidRect(FloatRectError::CoordinatesOutOfRange); + for invalid in [limit * 2.0, -limit * 2.0, <$scalar>::NAN, <$scalar>::INFINITY] { + // Exercise both initialization and expansion of variable-stroke bounds. + for points in [[[invalid, 0.0], [0.0, 0.0]], [[0.0, 0.0], [invalid, 0.0]]] { + assert!(matches!( + FloatOverlay::with_subj_and_clip_fixed_scale(&points, &square, 1.0), + Err(e) if e == error + )); + assert_eq!(points.stroke_fixed_scale(StrokeStyle::new(1.0), false, 1.0), Err(error)); + assert_eq!(points.outline_fixed_scale(&OutlineStyle::new(1.0), 1.0), Err(error)); + let vertices = points.map(|p| StrokeVertex::new(p, 1.0)); + assert_eq!(vertices.variable_stroke_fixed_scale(VariableStrokeStyle::new(), 1.0), Err(error)); + } + } + + // Valid source coordinates can still yield unsupported padded bounds. + let path = [[limit, 0.0], [limit, limit / 4.0]]; + assert_eq!(path.stroke_fixed_scale(StrokeStyle::new(limit), false, 1.0 / limit), Err(error)); + assert_eq!(path.outline_fixed_scale(&OutlineStyle::new(limit / 4.0), 1.0 / limit), Err(error)); + let vertices = path.map(|p| StrokeVertex::new(p, limit)); + assert_eq!(vertices.variable_stroke_fixed_scale(VariableStrokeStyle::new(), 1.0 / limit), Err(error)); + + // Bounds errors must not clear a caller's existing output buffer. + let mut output = FloatFlatContoursBuffer::default(); + output.add_contour(&[[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]); + let original = output.clone(); + assert_eq!(path.stroke_fixed_scale_into(StrokeStyle::new(limit), false, 1.0 / limit, &mut output), Err(error)); + assert_eq!(path.outline_fixed_scale_into(&OutlineStyle::new(limit / 4.0), 1.0 / limit, &mut output), Err(error)); + assert_eq!(vertices.variable_stroke_fixed_scale_into(VariableStrokeStyle::new(), 1.0 / limit, &mut output), Err(error)); + assert_eq!(output.points, original.points); + assert_eq!(output.ranges, original.ranges); + + // Reciprocal overflow must be evaluated in the original scalar type. + let tiny = <$scalar>::from_bits(1); + assert_eq!(FixedScaleOverlayError::validate_scale(tiny), Err(FixedScaleOverlayError::ScaleTooSmall)); + for path in [vec![], vec![[0.0, 0.0], [1.0, 0.0]]] { + assert!(matches!(FloatOverlay::with_subj_and_clip_fixed_scale(&path, &path, tiny), Err(FixedScaleOverlayError::ScaleTooSmall))); + assert_eq!(path.stroke_fixed_scale(StrokeStyle::new(1.0), false, tiny), Err(FixedScaleOverlayError::ScaleTooSmall)); + assert_eq!(path.outline_fixed_scale(&OutlineStyle::new(1.0), tiny), Err(FixedScaleOverlayError::ScaleTooSmall)); + let vertices: Vec<_> = path.iter().map(|&p| StrokeVertex::new(p, 1.0)).collect(); + assert_eq!(vertices.variable_stroke_fixed_scale(VariableStrokeStyle::new(), tiny), Err(FixedScaleOverlayError::ScaleTooSmall)); + } + let empty: Vec<[$scalar; 2]> = vec![]; + assert!(empty.stroke_fixed_scale(StrokeStyle::new(1.0), false, 1.0).unwrap().is_empty()); + assert!(empty.outline_fixed_scale(&OutlineStyle::new(1.0), 1.0).unwrap().is_empty()); + let empty_vertices: Vec> = vec![]; + assert!(empty_vertices.variable_stroke_fixed_scale(VariableStrokeStyle::new(), 1.0).unwrap().is_empty()); + } + }; +} + +coordinate_contract!(f32_coordinate_contract, f32); +coordinate_contract!(f64_coordinate_contract, f64); + +#[test] +fn automatic_i16_budget_and_fixed_scale_rejection() { + let h = 1.99999; + let square = [[-h, -h], [h, -h], [h, h], [-h, h]]; + let mut overlay = FloatOverlay::<_, i16>::from_subj_and_clip(&square, &square); + let result = overlay.overlay(OverlayRule::Intersect, FillRule::NonZero); + assert_eq!(result.len(), 1); + assert_eq!(result[0][0].len(), 4); + for p in &result[0][0] { + assert!((p[0].abs() - 2.0).abs() < 0.001); + assert!((p[1].abs() - 2.0).abs() < 0.001); + } + let mut predicate = FloatPredicateOverlay::<_, i16>::from_subj_and_clip(&square, &square); + assert!(predicate.intersects()); + assert!(matches!( + FloatOverlay::<_, i16>::from_subj_and_clip_fixed_scale(&square, &square, 8192.0), + Err(FixedScaleOverlayError::ScaleTooLarge) + )); + assert!(FloatOverlay::<_, i16>::from_subj_and_clip_fixed_scale(&square, &square, 4096.0).is_ok()); +} + +#[test] +#[should_panic(expected = "Invalid overlay bounds")] +fn infallible_overlay_rejects_invalid_bounds() { + let _ = FloatOverlay::with_subj(&[[f64::NAN, 0.0]]); +} + +#[test] +#[should_panic(expected = "Invalid offset bounds")] +fn infallible_stroke_rejects_padded_bounds() { + let limit = ::MAX_COORDINATE; + let _ = [[limit, 0.0], [limit, 1.0]].stroke(StrokeStyle::new(limit), false); +} diff --git a/iOverlay/tests/float_point_adapter.rs b/iOverlay/tests/float_point_adapter.rs index 58170a28..9200df8f 100644 --- a/iOverlay/tests/float_point_adapter.rs +++ b/iOverlay/tests/float_point_adapter.rs @@ -18,8 +18,10 @@ mod tests { [s * 1.0, s * 0.0], ]]; - let adapter_100 = FloatPointAdapter::<_, i32>::new(FloatRect::new(-100.0, 100.0, -100.0, 100.0)); - let adapter_1000 = FloatPointAdapter::<_, i32>::new(FloatRect::new(-1000.0, 1000.0, -1000.0, 1000.0)); + let adapter_100 = + FloatPointAdapter::<_, i32>::new(FloatRect::new(-100.0, 100.0, -100.0, 100.0).unwrap()); + let adapter_1000 = + FloatPointAdapter::<_, i32>::new(FloatRect::new(-1000.0, 1000.0, -1000.0, 1000.0).unwrap()); let subj_100 = FloatOverlay::with_adapter(adapter_100, shape.len()) .unsafe_add_source(&shape, ShapeType::Subject) @@ -50,13 +52,16 @@ mod tests { [s * 1.0, s * 0.0], ]]; - let rect = FloatRect::with_iter(shape.iter_paths().flatten()).unwrap(); + let rect = FloatRect::with_iter(shape.iter_paths().flatten()) + .unwrap() + .unwrap(); let buffer_rect = FloatRect::new( rect.min_x - 0.1, rect.max_x + 0.1, rect.min_y - 0.1, rect.max_y + 0.1, - ); + ) + .unwrap(); let adapter_100 = FloatPointAdapter::<_, i32>::with_scale(buffer_rect.clone(), 100.0); let adapter_1000 = FloatPointAdapter::<_, i32>::with_scale(buffer_rect, 1000.0); diff --git a/iOverlay/tests/integer_range_tests.rs b/iOverlay/tests/integer_range_tests.rs index 5738f553..8e36aa32 100644 --- a/iOverlay/tests/integer_range_tests.rs +++ b/iOverlay/tests/integer_range_tests.rs @@ -221,7 +221,7 @@ fn conservative_float_scale_keeps_rounded_i16_span_in_range() { use i_float::float::rect::FloatRect; let half_extent = 1.99999; - let rect = FloatRect::new(-half_extent, half_extent, -half_extent, half_extent); + let rect = FloatRect::new(-half_extent, half_extent, -half_extent, half_extent).unwrap(); let min = [-half_extent, -half_extent]; let max = [half_extent, half_extent]; @@ -252,7 +252,7 @@ fn explicit_float_coordinate_budget() { 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 rect = FloatRect::new(-half_extent, half_extent, -half_extent, half_extent).unwrap(); let adapter = FloatPointAdapter::<[f64; 2], I>::with_coordinate_bits(rect, I::BITS - 3); let points = vec![ [-half_extent, -half_extent], diff --git a/iOverlay/tests/mesh_scale_tests.rs b/iOverlay/tests/mesh_scale_tests.rs index 75b9acf6..7937f25c 100644 --- a/iOverlay/tests/mesh_scale_tests.rs +++ b/iOverlay/tests/mesh_scale_tests.rs @@ -26,7 +26,8 @@ fn normalized_area(shapes: &[Vec>], scale: f64) -> f64 { #[test] fn float_overlay_preserves_rectangles_at_extreme_scales() { - for exponent in [600, -600] { + // Leave room for the rectangle width within the inclusive 2^500 limit. + for exponent in [496, -600] { let scale = 2.0_f64.powi(exponent); let rectangle = [ [0.0, -scale], @@ -46,7 +47,7 @@ fn float_overlay_preserves_rectangles_at_extreme_scales() { #[test] #[ignore = "Known extreme-scale mesh bug; deferred by request"] fn stroke_preserves_relative_area_across_coordinate_scales() { - for exponent in [0, 400, -400, 600, -600] { + for exponent in [0, 400, -400, 496, -600] { let scale = 2.0_f64.powi(exponent); let path = [[0.0, 0.0], [10.0 * scale, 0.0]]; let shapes = path.stroke(StrokeStyle::new(2.0 * scale), false); @@ -74,7 +75,8 @@ fn stroke_preserves_relative_area_at_small_coordinates() { #[test] #[ignore = "Known extreme-scale mesh bug; deferred by request"] fn f32_stroke_preserves_relative_area_at_large_coordinates() { - check_f32_stroke(80); + // The path and stroke padding must fit within 2^60. + check_f32_stroke(56); } #[test] @@ -105,7 +107,7 @@ fn check_f32_stroke(exponent: i32) { #[test] #[ignore = "Known extreme-scale mesh bug; deferred by request"] fn bevel_outline_preserves_relative_area_at_large_coordinates() { - let scale = 2.0_f64.powi(600); + let scale = 2.0_f64.powi(496); let path = [ [0.0, 0.0], [10.0 * scale, 0.0], @@ -123,7 +125,7 @@ fn bevel_outline_preserves_relative_area_at_large_coordinates() { #[test] #[ignore = "Known extreme-scale mesh bug; deferred by request"] fn outline_preserves_relative_area_across_coordinate_scales() { - for exponent in [0, 400, -400, 600, -600] { + for exponent in [0, 400, -400, 496, -600] { let scale = 2.0_f64.powi(exponent); let path = [ [0.0, 0.0], diff --git a/iOverlay/tests/output_consistency_tests.rs b/iOverlay/tests/output_consistency_tests.rs index a76e71a2..d424e453 100644 --- a/iOverlay/tests/output_consistency_tests.rs +++ b/iOverlay/tests/output_consistency_tests.rs @@ -32,7 +32,7 @@ fn flat_and_vector_outputs_match_shapes_with_area_filtering() { for threshold in [0, 1, 10, 50] { let mut regular = Overlay::from_subj_and_clip(&a, &b); regular.options.min_output_area = threshold; - regular.options.preserve_output_collinear = case % 2 == 0; + regular.options.preserve_output_collinear = true; regular.options.output_direction = if case % 3 == 0 { ContourDirection::Clockwise } else { From 03d6db27bf0927b2dfae40b67a09d35f190af059 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Sat, 19 Sep 2026 12:21:05 +0300 Subject: [PATCH 41/44] move to new adapter api --- iOverlay/README.md | 5 +-- iOverlay/src/core/integer.rs | 13 ++++---- iOverlay/src/float/adapter.rs | 33 ------------------- iOverlay/src/float/mod.rs | 5 +-- iOverlay/src/float/overlay.rs | 15 ++++----- iOverlay/src/float/relate.rs | 5 ++- iOverlay/src/float/scale.rs | 11 +++---- iOverlay/src/float/string_overlay.rs | 7 ++-- iOverlay/src/mesh/float/outline/offset.rs | 5 ++- iOverlay/src/mesh/float/stroke/offset.rs | 5 ++- .../src/mesh/float/variable_stroke/offset.rs | 5 ++- .../tests/float_coordinate_contract_tests.rs | 2 +- 12 files changed, 37 insertions(+), 74 deletions(-) delete mode 100644 iOverlay/src/float/adapter.rs diff --git a/iOverlay/README.md b/iOverlay/README.md index 6fa52a51..6d539e3d 100644 --- a/iOverlay/README.md +++ b/iOverlay/README.md @@ -638,9 +638,10 @@ these bounds can cause overflow or incorrect results. Use a wider engine or resc larger inputs. See the [range derivation and arithmetic audit](readme/integer_range.md) for details. For float APIs, the limits apply after conversion. Automatic conversion and checked -fixed-scale methods use a conservative budget of `I::BITS - 3` coordinate bits. +fixed-scale methods use `FloatPointAdapter::CONSERVATIVE_COORDINATE_BITS` +(`I::BITS - 3`), reserving an extra bit for rounding inside the arithmetic range. Custom adapters must respect the integer range; use -`FloatPointAdapter::with_coordinate_bits(rect, I::BITS - 3)` for the same budget. +`FloatPointAdapter::new_conservative(rect)` for the same budget. ## Floating-Point Coordinate Limits diff --git a/iOverlay/src/core/integer.rs b/iOverlay/src/core/integer.rs index 1de4b187..6902e395 100644 --- a/iOverlay/src/core/integer.rs +++ b/iOverlay/src/core/integer.rs @@ -21,12 +21,13 @@ //! //! These limits concern the integer coordinates after conversion, not the //! original floating-point coordinates, which have their own [limits](crate::float). -//! Automatic float APIs and checked fixed-scale APIs use `I::BITS - 3` coordinate -//! bits. For a custom adapter with the same 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. +//! Automatic float APIs and checked fixed-scale APIs use the adapter's +//! conservative coordinate budget, `CONSERVATIVE_COORDINATE_BITS = I::BITS - 3`. +//! This reserves an extra bit for rounding inside the arithmetic range. +//! For a custom adapter with the same budget, use +//! [`FloatPointAdapter::new_conservative`](i_float::adapter::FloatPointAdapter::new_conservative). +//! Converted magnitudes are bounded by `2^(N - 3)` (8,192 for `i16`), including +//! both endpoints. A custom unchecked scale must respect the integer range too. use i_float::int::number::int::IntNumber; use i_key_sort::sort::key::SortKey; diff --git a/iOverlay/src/float/adapter.rs b/iOverlay/src/float/adapter.rs deleted file mode 100644 index 4610662c..00000000 --- a/iOverlay/src/float/adapter.rs +++ /dev/null @@ -1,33 +0,0 @@ -use super::scale::FixedScaleOverlayError; -use crate::core::integer::OverlayInt; -use i_float::adapter::FloatPointAdapter; -use i_float::float::{compatible::FloatPointCompatible, rect::FloatRect}; - -// Reserve enough bits for the engine's coordinate differences and products, -// including rounding at the bounds. -pub(crate) fn adapter_with_iter<'a, P, I>(iter: impl Iterator) -> FloatPointAdapter -where - P: FloatPointCompatible + 'a, - I: OverlayInt, -{ - let rect = FloatRect::with_iter(iter) - .expect("Invalid overlay bounds") - .unwrap_or(FloatRect::zero()); - FloatPointAdapter::with_coordinate_bits(rect, I::BITS - 3) -} - -pub(crate) fn adapter_with_iter_and_scale<'a, P, I>( - iter: impl Iterator, - scale: P::Scalar, -) -> Result, FixedScaleOverlayError> -where - P: FloatPointCompatible + 'a, - I: OverlayInt, -{ - let rect = FloatRect::with_iter(iter)?.unwrap_or(FloatRect::zero()); - Ok(FloatPointAdapter::try_with_scale_and_coordinate_bits( - rect, - scale, - I::BITS - 3, - )?) -} diff --git a/iOverlay/src/float/mod.rs b/iOverlay/src/float/mod.rs index a160466e..f09340e7 100644 --- a/iOverlay/src/float/mod.rs +++ b/iOverlay/src/float/mod.rs @@ -3,12 +3,13 @@ //! their padding, must also fit. Infallible APIs panic on invalid bounds; //! fixed-scale APIs return an error. Empty valid input remains supported. //! -//! Automatic conversion uses `I::BITS - 3` coordinate bits. Fixed-scale APIs +//! Automatic conversion uses the conservative coordinate budget +//! (`FloatPointAdapter::CONSERVATIVE_COORDINATE_BITS`, or `I::BITS - 3`), +//! reserving an extra bit for rounding inside the arithmetic range. Fixed-scale APIs //! enforce the same budget and require a positive finite scale with a finite //! reciprocal in the input scalar type. Custom adapters remain the caller's //! responsibility; see the integer coordinate contract in [`crate::core::integer`]. -pub(crate) mod adapter; pub mod clip; pub mod graph; pub mod hierarchy; diff --git a/iOverlay/src/float/overlay.rs b/iOverlay/src/float/overlay.rs index 4102d293..6ecf17f4 100644 --- a/iOverlay/src/float/overlay.rs +++ b/iOverlay/src/float/overlay.rs @@ -7,7 +7,6 @@ use crate::core::integer::OverlayInt; use crate::core::overlay::{ContourDirection, IntOverlayOptions, Overlay, ShapeType}; use crate::core::overlay_rule::OverlayRule; use crate::core::solver::Solver; -use crate::float::adapter::adapter_with_iter; use crate::float::graph::FloatOverlayGraph; use crate::float::hierarchy::FloatFlatShapeHierarchy; use crate::i_shape::source::float::resource::ShapeResource; @@ -113,7 +112,7 @@ where #[inline] pub fn new_empty(options: OverlayOptions, solver: Solver, capacity: usize) -> Self { let clean_result = options.clean_result; - let adapter = FloatPointAdapter::new(FloatRect::zero()); + let adapter = FloatPointAdapter::new_conservative(FloatRect::zero()); let overlay = Overlay::new_custom(capacity, options.int_default(), solver); Self { overlay, @@ -151,7 +150,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = adapter_with_iter(iter); + let adapter = FloatPointAdapter::with_iter_conservative(iter); let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); @@ -180,7 +179,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = adapter_with_iter(iter); + let adapter = FloatPointAdapter::with_iter_conservative(iter); let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); @@ -200,7 +199,7 @@ where R: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().flatten(); - let adapter = adapter_with_iter(iter); + let adapter = FloatPointAdapter::with_iter_conservative(iter); let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); Self::with_adapter(adapter, subj_capacity).unsafe_add_source(subj, ShapeType::Subject) @@ -219,7 +218,7 @@ where R: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().flatten(); - let adapter = adapter_with_iter(iter); + let adapter = FloatPointAdapter::with_iter_conservative(iter); let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); Self::new_custom(adapter, options, solver, subj_capacity).unsafe_add_source(subj, ShapeType::Subject) @@ -300,7 +299,7 @@ where self.clear(); let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - self.update_adapter(adapter_with_iter(iter)); + self.update_adapter(FloatPointAdapter::with_iter_conservative(iter)); self.add_source(subj, ShapeType::Subject); self.add_source(clip, ShapeType::Clip); } @@ -324,7 +323,7 @@ where { self.clear(); let iter = subj.iter_paths().flatten(); - self.update_adapter(adapter_with_iter(iter)); + self.update_adapter(FloatPointAdapter::with_iter_conservative(iter)); self.add_source(subj, ShapeType::Subject); } diff --git a/iOverlay/src/float/relate.rs b/iOverlay/src/float/relate.rs index ea94856e..013c863f 100644 --- a/iOverlay/src/float/relate.rs +++ b/iOverlay/src/float/relate.rs @@ -3,7 +3,6 @@ use crate::core::integer::OverlayInt; use crate::core::overlay::ShapeType; use crate::core::relate::PredicateOverlay; use crate::core::solver::Solver; -use crate::float::adapter::adapter_with_iter; use i_float::adapter::FloatPointAdapter; use i_float::float::compatible::FloatPointCompatible; use i_shape::source::float::resource::ShapeResource; @@ -82,7 +81,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = adapter_with_iter::<_, I>(iter); + let adapter = FloatPointAdapter::<_, I>::with_iter_conservative(iter); let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); @@ -107,7 +106,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = adapter_with_iter::<_, I>(iter); + let adapter = FloatPointAdapter::<_, I>::with_iter_conservative(iter); let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); diff --git a/iOverlay/src/float/scale.rs b/iOverlay/src/float/scale.rs index a0196858..ea11e524 100644 --- a/iOverlay/src/float/scale.rs +++ b/iOverlay/src/float/scale.rs @@ -1,4 +1,3 @@ -use super::adapter::adapter_with_iter_and_scale; use crate::core::fill_rule::FillRule; use crate::core::integer::OverlayInt; use crate::core::overlay::ShapeType; @@ -6,7 +5,7 @@ use crate::core::overlay_rule::OverlayRule; use crate::core::solver::Solver; use crate::float::overlay::{FloatOverlay, OverlayOptions}; use crate::float::relate::FloatPredicateOverlay; -use i_float::adapter::FloatPointAdapterScaleError; +use i_float::adapter::{FloatPointAdapter, FloatPointAdapterScaleError}; use i_float::float::compatible::FloatPointCompatible; use i_float::float::number::FloatNumber; use i_float::float::rect::FloatRectError; @@ -194,7 +193,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = adapter_with_iter_and_scale(iter, scale)?; + let adapter = FloatPointAdapter::try_with_iter_and_scale_conservative(iter, scale)?; let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); @@ -230,7 +229,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = adapter_with_iter_and_scale(iter, scale)?; + let adapter = FloatPointAdapter::try_with_iter_and_scale_conservative(iter, scale)?; let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); @@ -303,7 +302,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = adapter_with_iter_and_scale(iter, scale)?; + let adapter = FloatPointAdapter::try_with_iter_and_scale_conservative(iter, scale)?; let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); @@ -335,7 +334,7 @@ where R1: ShapeResource

+ ?Sized, { let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - let adapter = adapter_with_iter_and_scale(iter, scale)?; + let adapter = FloatPointAdapter::try_with_iter_and_scale_conservative(iter, scale)?; let subj_capacity = subj.iter_paths().fold(0, |s, c| s + c.len()); let clip_capacity = clip.iter_paths().fold(0, |s, c| s + c.len()); diff --git a/iOverlay/src/float/string_overlay.rs b/iOverlay/src/float/string_overlay.rs index e14d16bf..56fdca24 100644 --- a/iOverlay/src/float/string_overlay.rs +++ b/iOverlay/src/float/string_overlay.rs @@ -1,7 +1,6 @@ use crate::core::fill_rule::FillRule; use crate::core::integer::OverlayInt; use crate::core::solver::Solver; -use crate::float::adapter::{adapter_with_iter, adapter_with_iter_and_scale}; use crate::float::scale::FixedScaleOverlayError; use crate::float::string_graph::FloatStringGraph; use crate::string::clip::ClipRule; @@ -34,7 +33,7 @@ where /// - `adapter`: A `FloatPointAdapter` instance responsible for coordinate conversion between /// float and integer values, ensuring accuracy during geometric transformations. /// Use `FloatPointAdapter::with_scale` to set a fixed scale, or - /// `FloatPointAdapter::with_coordinate_bits(rect, I::BITS - 3)` for automatic + /// `FloatPointAdapter::new_conservative(rect)` for automatic /// scaling. Custom scales must respect the integer coordinate range. /// - `capacity`: Initial capacity for storing segments, ideally matching the total number of /// segments for efficient memory allocation. @@ -65,7 +64,7 @@ where R1: ShapeResource

, { let iter = shape.iter_paths().chain(string.iter_paths()).flatten(); - let adapter = adapter_with_iter(iter); + let adapter = FloatPointAdapter::with_iter_conservative(iter); let shape_capacity = shape.iter_paths().fold(0, |s, c| s + c.len()); let string_capacity = string.iter_paths().fold(0, |s, c| s + c.len()); @@ -88,7 +87,7 @@ where R1: ShapeResource

, { let iter = shape.iter_paths().chain(string.iter_paths()).flatten(); - let adapter = adapter_with_iter_and_scale(iter, scale)?; + let adapter = FloatPointAdapter::try_with_iter_and_scale_conservative(iter, scale)?; let shape_capacity = shape.iter_paths().fold(0, |s, c| s + c.len()); let string_capacity = string.iter_paths().fold(0, |s, c| s + c.len()); diff --git a/iOverlay/src/mesh/float/outline/offset.rs b/iOverlay/src/mesh/float/outline/offset.rs index 0834ce59..a0653fc4 100644 --- a/iOverlay/src/mesh/float/outline/offset.rs +++ b/iOverlay/src/mesh/float/outline/offset.rs @@ -405,7 +405,7 @@ where let mut rect = FloatRect::with_iter(source.iter_paths().flatten())?.unwrap_or(FloatRect::zero()); rect.add_offset(additional_offset)?; - let adapter = FloatPointAdapter::::with_coordinate_bits(rect, I::BITS - 3); + let adapter = FloatPointAdapter::::new_conservative(rect); Ok(Some(Self { style: OutlineStyle { @@ -420,8 +420,7 @@ where fn apply_scale(&mut self, scale: f64) -> Result<(), FixedScaleOverlayError> { let s = P::Scalar::from_float(scale); - self.adapter = - FloatPointAdapter::try_with_scale_and_coordinate_bits(*self.adapter.rect(), s, I::BITS - 3)?; + self.adapter = FloatPointAdapter::try_with_scale_conservative(*self.adapter.rect(), s)?; Ok(()) } diff --git a/iOverlay/src/mesh/float/stroke/offset.rs b/iOverlay/src/mesh/float/stroke/offset.rs index bb463f1d..cf88aa0b 100644 --- a/iOverlay/src/mesh/float/stroke/offset.rs +++ b/iOverlay/src/mesh/float/stroke/offset.rs @@ -457,14 +457,13 @@ where let mut rect = FloatRect::with_iter(source.iter_paths().flatten())?.unwrap_or(FloatRect::zero()); rect.add_offset(a)?; - let adapter = FloatPointAdapter::::with_coordinate_bits(rect, I::BITS - 3); + let adapter = FloatPointAdapter::::new_conservative(rect); Ok(Some(Self { r, style, adapter })) } fn apply_scale(&mut self, scale: P::Scalar) -> Result<(), FixedScaleOverlayError> { - self.adapter = - FloatPointAdapter::try_with_scale_and_coordinate_bits(*self.adapter.rect(), scale, I::BITS - 3)?; + self.adapter = FloatPointAdapter::try_with_scale_conservative(*self.adapter.rect(), scale)?; Ok(()) } diff --git a/iOverlay/src/mesh/float/variable_stroke/offset.rs b/iOverlay/src/mesh/float/variable_stroke/offset.rs index c4d65fd4..de1d677f 100644 --- a/iOverlay/src/mesh/float/variable_stroke/offset.rs +++ b/iOverlay/src/mesh/float/variable_stroke/offset.rs @@ -283,7 +283,7 @@ where return Ok(None); }; rect.add_offset(P::Scalar::from_float(1.1) * max_radius)?; - let adapter = FloatPointAdapter::::with_coordinate_bits(rect, I::BITS - 3); + let adapter = FloatPointAdapter::::new_conservative(rect); Ok(Some(Self { max_radius, @@ -293,8 +293,7 @@ where } fn apply_scale(&mut self, scale: P::Scalar) -> Result<(), FixedScaleOverlayError> { - self.adapter = - FloatPointAdapter::try_with_scale_and_coordinate_bits(*self.adapter.rect(), scale, I::BITS - 3)?; + self.adapter = FloatPointAdapter::try_with_scale_conservative(*self.adapter.rect(), scale)?; Ok(()) } diff --git a/iOverlay/tests/float_coordinate_contract_tests.rs b/iOverlay/tests/float_coordinate_contract_tests.rs index 2b0e6aca..56b79bd2 100644 --- a/iOverlay/tests/float_coordinate_contract_tests.rs +++ b/iOverlay/tests/float_coordinate_contract_tests.rs @@ -95,7 +95,7 @@ fn automatic_i16_budget_and_fixed_scale_rejection() { } #[test] -#[should_panic(expected = "Invalid overlay bounds")] +#[should_panic(expected = "Invalid adapter bounds")] fn infallible_overlay_rejects_invalid_bounds() { let _ = FloatOverlay::with_subj(&[[f64::NAN, 0.0]]); } From f380105bceb49b3f9c9fdffe4a49daeb4f417e67 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Sat, 19 Sep 2026 14:26:02 +0300 Subject: [PATCH 42/44] fix miter near parallel case --- iOverlay/README.md | 11 +- iOverlay/docs/stroke_math.md | 21 +++- iOverlay/src/mesh/float/outline/offset.rs | 2 + iOverlay/src/mesh/float/style.rs | 39 +++++- iOverlay/src/mesh/int/join.rs | 30 +++-- iOverlay/src/mesh/int/math/backend.rs | 9 +- iOverlay/src/mesh/int/math/float.rs | 2 +- iOverlay/src/mesh/int/math/integer.rs | 6 +- iOverlay/src/mesh/int/outline/build.rs | 10 +- iOverlay/src/mesh/int/stroke/builder.rs | 2 +- iOverlay/src/mesh/int/style.rs | 33 ++++- iOverlay/tests/miter_regression_tests.rs | 143 +++++++++++++++++++++- 12 files changed, 270 insertions(+), 38 deletions(-) diff --git a/iOverlay/README.md b/iOverlay/README.md index 6d539e3d..61d7c055 100644 --- a/iOverlay/README.md +++ b/iOverlay/README.md @@ -506,9 +506,14 @@ strokes use round caps and joins. Round geometry uses `ArcOptions`, including configurable CORDIC rotation precision (default: 5). Integer construction math limits miter joins to a minimum interior angle of -5 degrees, clipping sharper corners. Almost straight corners with interior -angles above 175 degrees use bevel joins to avoid unstable intersections of -rounded offset lines. Float construction math retains its existing angle policy. +5 degrees, clipping sharper corners. Float construction math retains its +1.8-degree minimum. In both modes, almost straight corners with interior angles +above 175 degrees use bevel joins by default to avoid unstable intersections of +rounded offset lines. Stroke and outline styles expose `.miter_min_turn(angle)` +to configure this minimum turn independently of the sharp-corner clipping angle: +use `Angle` in the integer API or radians in the float API. The default is 5 degrees +for both math modes. Zero disables this guard; smaller values allow less stable +intersections. Integer distances use input coordinate units without automatic rescaling. Stroke radius is `ceil(max(width, 0) / 2)`; radii at most 1 are degenerate. diff --git a/iOverlay/docs/stroke_math.md b/iOverlay/docs/stroke_math.md index 84bc0487..5a098e73 100644 --- a/iOverlay/docs/stroke_math.md +++ b/iOverlay/docs/stroke_math.md @@ -17,10 +17,11 @@ Both input APIs support either math mode, and both modes retain integer coordinates and boolean operations internally. Integer miter construction clamps the requested minimum interior angle to at -least 5 degrees and clips sharper corners. Interior angles above 175 degrees -use bevel joins. This uses the turn angle already computed for the join, without -additional normalization or trigonometry. Float mode retains its existing angle -policy, including ordinary miter intersections at nearly straight corners. +least 5 degrees and clips sharper corners. In both math modes, interior angles +above 175 degrees use bevel joins by default to avoid unstable intersections of rounded +offset lines. This uses the turn angle already computed for the join, without +additional normalization or trigonometry. Float mode retains its existing +minimum interior angle for sharp corners. For `IntLineJoin::Miter`, the lower limit on the requested minimum is 5 degrees in Integer mode or 0.01*pi (1.8 degrees) in Float mode; the upper limit is one `Angle` unit below pi. The floating-point `LineJoin::Miter` style first clamps @@ -28,6 +29,13 @@ its parameter to 0.01*pi..=0.99*pi (1.8..=178.2 degrees), before the selected math mode applies its construction limits. Angular thresholds are quantized to the `Angle` representation. +Stroke and outline styles provide `.miter_min_turn(angle)` for the near-straight +bevel cutoff, independent of the sharp-corner clipping limit. It defaults to +5 degrees in both math modes. Integer styles accept `Angle`; float styles accept +radians. Construction clamps the cutoff to 0..=pi; float NaN uses the default. +Zero disables the cutoff, and smaller values allow less stable intersections of +rounded offset lines. Bevel and round joins ignore this setting. + ```rust use i_overlay::mesh::float::{stroke::offset::StrokeOffset, style::StrokeStyle}; use i_overlay::mesh::math::MathMode; @@ -57,7 +65,8 @@ does not eliminate coordinate quantization or rounding-sensitive intersections. The modes may produce different rounded vertices and arc tessellations. Float mode does not promise cross-platform bitwise reproducibility. Code using -exhaustive style struct literals must supply the `math` field. +exhaustive stroke and outline style struct literals must supply the `math` and +`miter_min_turn` fields. Variable-width strokes also select the tangent-contact calculation through a private `VariableStrokeMath` extension of `MeshMath`. Integer mode retains the @@ -76,7 +85,7 @@ uses the same traversal and records the same emitted geometry. The style-free ## Validation - Integer mesh/arc/outline regression tests exercise the default construction mode. -- Miter regressions cover almost straight strokes and shrinking outlines, the 175-degree bevel cutoff, and the 5-degree clipping floor, with separate Float controls. +- Miter regressions cover almost straight strokes, including translated Float inputs near the coordinate limit, shrinking outlines, the shared 175-degree bevel cutoff, and the Integer-only 5-degree clipping floor. - Both math modes cover constant-width round-stroke vertex disks, path reversal, coarse caps, empty paths, duplicates, and reused flat output. - Float arc tests check ordered samples, maximum angular gaps, approximate unit length within a numerical tolerance, and allocation reuse for i16/i32/i64. - Large-origin i64 tests translate the input by 2^60 and compare the translated-back stroke. diff --git a/iOverlay/src/mesh/float/outline/offset.rs b/iOverlay/src/mesh/float/outline/offset.rs index a0653fc4..9444425e 100644 --- a/iOverlay/src/mesh/float/outline/offset.rs +++ b/iOverlay/src/mesh/float/outline/offset.rs @@ -412,6 +412,7 @@ where outer_offset: style.outer_offset, inner_offset: style.inner_offset, join: style.join.clone(), + miter_min_turn: style.miter_min_turn, math: style.math, }, adapter, @@ -429,6 +430,7 @@ where outer_offset: self.adapter.round_len_to_int(self.style.outer_offset), inner_offset: self.adapter.round_len_to_int(self.style.inner_offset), join: (&self.style.join).into(), + miter_min_turn: super::super::style::miter_min_turn_angle(self.style.miter_min_turn), math: self.style.math, } } diff --git a/iOverlay/src/mesh/float/style.rs b/iOverlay/src/mesh/float/style.rs index 3b5e0c13..ab558426 100644 --- a/iOverlay/src/mesh/float/style.rs +++ b/iOverlay/src/mesh/float/style.rs @@ -19,6 +19,16 @@ pub(super) fn angle_from_radians(radians: T) -> Angle { }) } +pub(super) fn miter_min_turn_angle(radians: T) -> Angle { + let angle = radians.to_f64(); + let angle = if angle.is_nan() { + PI / 36.0 + } else { + angle.clamp(0.0, PI) + }; + angle_from_radians(angle) +} + /// The endpoint style of a line. #[derive(Debug, Clone)] pub enum LineCap { @@ -42,8 +52,8 @@ pub enum LineJoin { /// The parameter is the minimum interior angle in radians, clamped to /// 0.01*pi..=0.99*pi (1.8..=178.2 degrees) before integer conversion. /// With Integer construction math, the effective minimum is at least - /// 5 degrees, and interior angles above 175 degrees use bevel joins. - /// Float construction math uses the converted angle without that cutoff. + /// 5 degrees. Both math modes default to bevel joins for turns below + /// 5 degrees; the style's `miter_min_turn` configures this cutoff independently. Miter(T), /// Creates an arc corner where two lines meet. /// The arc is approximated using a group of segments, where the parameter `Angle` @@ -62,6 +72,11 @@ pub struct StrokeStyle { pub end_cap: LineCap

, /// The join style where two lines meet. pub join: LineJoin, + /// Miter turns below this angle in radians use bevel joins in both math modes. + /// Defaults to 5 degrees (pi/36); clamped to 0..=pi, with NaN using the default. + /// Zero disables the cutoff. Smaller values allow less stable intersections. + /// Ignored for bevel and round joins. + pub miter_min_turn: P::Scalar, /// Arithmetic for stroke construction. Integer remains the default. pub math: MathMode, } @@ -72,6 +87,11 @@ pub struct OutlineStyle { pub outer_offset: T, pub inner_offset: T, pub join: LineJoin, + /// Miter turns below this angle in radians use bevel joins in both math modes. + /// Defaults to 5 degrees (pi/36); clamped to 0..=pi, with NaN using the default. + /// Zero disables the cutoff. Smaller values allow less stable intersections. + /// Ignored for bevel and round joins. + pub miter_min_turn: T, /// Arithmetic used to construct offsets and joins. pub math: MathMode, } @@ -161,6 +181,12 @@ impl StrokeStyle

{ self } + /// Sets the near-straight bevel cutoff in radians, independently of the miter clipping angle. + pub fn miter_min_turn(mut self, angle: P::Scalar) -> Self { + self.miter_min_turn = angle; + self + } + /// Selects construction arithmetic; the final boolean operation stays integer. pub fn math(mut self, math: MathMode) -> Self { self.math = math; @@ -195,6 +221,7 @@ impl StrokeStyle

{ start_cap: cap(&self.start_cap), end_cap: cap(&self.end_cap), join: IntLineJoin::from(&self.join), + miter_min_turn: miter_min_turn_angle(self.miter_min_turn), math: self.math, } } @@ -234,6 +261,7 @@ impl Default for StrokeStyle

{ start_cap: LineCap::Butt, end_cap: LineCap::Butt, join: LineJoin::Bevel, + miter_min_turn: P::Scalar::from_float(PI / 36.0), math: MathMode::Integer, } } @@ -279,6 +307,12 @@ impl OutlineStyle { self.join = join; self } + + /// Sets the near-straight bevel cutoff in radians, independently of the miter clipping angle. + pub fn miter_min_turn(mut self, angle: T) -> Self { + self.miter_min_turn = angle; + self + } } impl Default for OutlineStyle { @@ -287,6 +321,7 @@ impl Default for OutlineStyle { outer_offset: T::from_float(1.0), inner_offset: T::from_float(1.0), join: LineJoin::Bevel, + miter_min_turn: T::from_float(PI / 36.0), math: MathMode::Integer, } } diff --git a/iOverlay/src/mesh/int/join.rs b/iOverlay/src/mesh/int/join.rs index f422fc22..4fd29ec4 100644 --- a/iOverlay/src/mesh/int/join.rs +++ b/iOverlay/src/mesh/int/join.rs @@ -12,21 +12,31 @@ use i_float::int::{point::IntPoint, unit_vector::UnitIntVector}; pub(super) enum Join = IntegerMath> { Bevel, - Miter { minimum: u32, sin: i32, cos: i32 }, + Miter { + minimum: u32, + min_turn: u32, + sin: i32, + cos: i32, + }, Round(M::Arc), } impl> Join { - pub(super) fn new(style: IntLineJoin) -> Self { + pub(super) fn new(style: IntLineJoin, min_turn: Angle) -> Self { match style { IntLineJoin::Bevel => Self::Bevel, IntLineJoin::Round(options) => Self::Round(M::Arc::new(options)), IntLineJoin::Miter(angle) => { - let min_angle = ((1u32 << 31) / 100).max(M::MITER_STABILITY_ANGLE); + let min_angle = ((1u32 << 31) / 100).max(M::MIN_MITER_ANGLE); let max_angle = (1u32 << 31) - 1; let minimum = angle.bits().clamp(min_angle, max_angle); let (sin, cos) = M::sin_cos(Angle::from_bits(minimum / 2)); - Self::Miter { minimum, sin, cos } + Self::Miter { + minimum, + min_turn: min_turn.bits().min(1u32 << 31), + sin, + cos, + } } } } @@ -38,7 +48,8 @@ impl> Join { } match style { IntLineJoin::Miter(_) => { - let Self::Miter { sin, .. } = Self::new(style) else { + // The near-straight cutoff does not change the miter reach limit. + let Self::Miter { sin, .. } = Self::new(style, Angle::from_bits(0)) else { unreachable!() }; mul_div::(radius, I::Wide::from_u32(1 << 30), I::Wide::from_u32(sin as u32)) + I::Wide::TWO @@ -81,7 +92,12 @@ impl> Join { } segments.push_non_degenerate(previous, b); } - Self::Miter { minimum, sin, cos } => { + Self::Miter { + minimum, + min_turn, + sin, + cos, + } => { let va = vector(incoming); let vb = vector(outgoing); let cross = va.cross_product(vb); @@ -91,7 +107,7 @@ impl> Join { } let turn = M::angle_between(incoming, outgoing).bits(); let turn = turn.min(turn.wrapping_neg()); - if turn < M::MITER_STABILITY_ANGLE { + if turn < *min_turn { // Almost straight: rounded offset points need not lie on // intersecting rays near the vertex. Close the gap directly. segments.push_non_degenerate(a, b); diff --git a/iOverlay/src/mesh/int/math/backend.rs b/iOverlay/src/mesh/int/math/backend.rs index 66c5ce87..6903916d 100644 --- a/iOverlay/src/mesh/int/math/backend.rs +++ b/iOverlay/src/mesh/int/math/backend.rs @@ -3,12 +3,15 @@ use i_float::int::{ angle::Angle, number::int::IntNumber, point::IntPoint, unit_vector::UnitIntVector, vector::IntVector, }; +/// Five degrees: avoid amplifying rounding errors in nearly parallel offset lines. +pub(crate) const DEFAULT_MITER_MIN_TURN: u32 = (1u32 << 31) / 36; + /// Construction arithmetic only; coordinates and topology remain integer. pub(crate) trait MeshMath: Copy { type Arc: ArcMath>; - /// Minimum interior angle and turn for ordinary miter intersections, in - /// Angle bits. Zero preserves the unrestricted near-straight behavior. - const MITER_STABILITY_ANGLE: u32; + /// Backend-specific minimum interior angle, in Angle bits. + /// The configurable near-straight cutoff is independent of this clipping limit. + const MIN_MITER_ANGLE: u32; fn sin_cos(angle: Angle) -> (i32, i32); fn angle_between(from: UnitIntVector, to: UnitIntVector) -> Angle; diff --git a/iOverlay/src/mesh/int/math/float.rs b/iOverlay/src/mesh/int/math/float.rs index a55ec45c..6c9dd498 100644 --- a/iOverlay/src/mesh/int/math/float.rs +++ b/iOverlay/src/mesh/int/math/float.rs @@ -9,7 +9,7 @@ pub(crate) struct FloatMath; impl MeshMath for FloatMath { type Arc = FloatArc; - const MITER_STABILITY_ANGLE: u32 = 0; + const MIN_MITER_ANGLE: u32 = 0; fn sin_cos(angle: Angle) -> (i32, i32) { angle.sin_cos_with_float() } diff --git a/iOverlay/src/mesh/int/math/integer.rs b/iOverlay/src/mesh/int/math/integer.rs index 89c40ee6..14f76bed 100644 --- a/iOverlay/src/mesh/int/math/integer.rs +++ b/iOverlay/src/mesh/int/math/integer.rs @@ -1,4 +1,4 @@ -use super::backend::MeshMath; +use super::backend::{DEFAULT_MITER_MIN_TURN, MeshMath}; use super::{direction, vector}; use crate::mesh::int::arc::IntegerArc; use i_float::int::number::{int::IntNumber, wide_int::WideIntNumber}; @@ -9,9 +9,7 @@ pub(crate) struct IntegerMath; impl MeshMath for IntegerMath { type Arc = IntegerArc; - // Five degrees: avoid amplifying direction and grid rounding errors when - // intersecting nearly parallel offset lines. - const MITER_STABILITY_ANGLE: u32 = (1u32 << 31) / 36; + const MIN_MITER_ANGLE: u32 = DEFAULT_MITER_MIN_TURN; #[inline] fn sin_cos(angle: Angle) -> (i32, i32) { angle.sin_cos() diff --git a/iOverlay/src/mesh/int/outline/build.rs b/iOverlay/src/mesh/int/outline/build.rs index 40d3dc35..0085d842 100644 --- a/iOverlay/src/mesh/int/outline/build.rs +++ b/iOverlay/src/mesh/int/outline/build.rs @@ -50,8 +50,14 @@ where Paths: IntoIterator, Path: ExactSizeIterator> + Clone, { - let mut outer_builder = OutlineBuilder::new(style.outer_offset, Join::::new(style.join)); - let mut inner_builder = OutlineBuilder::new(style.inner_offset, Join::::new(style.join)); + let mut outer_builder = OutlineBuilder::new( + style.outer_offset, + Join::::new(style.join, style.miter_min_turn), + ); + let mut inner_builder = OutlineBuilder::new( + style.inner_offset, + Join::::new(style.join, style.miter_min_turn), + ); let mut overlay = Overlay::new_custom(0, options, Default::default()); let mut contour_options = options; diff --git a/iOverlay/src/mesh/int/stroke/builder.rs b/iOverlay/src/mesh/int/stroke/builder.rs index 08dd7518..6905dc59 100644 --- a/iOverlay/src/mesh/int/stroke/builder.rs +++ b/iOverlay/src/mesh/int/stroke/builder.rs @@ -19,7 +19,7 @@ impl> StrokeBuilder { pub(super) fn new(style: &IntStrokeStyle) -> Self { Self { radius: stroke_radius(style), - join: Join::new(style.join), + join: Join::new(style.join, style.miter_min_turn), start: Cap::new(&style.start_cap), end: Cap::new(&style.end_cap), sections: Vec::new(), diff --git a/iOverlay/src/mesh/int/style.rs b/iOverlay/src/mesh/int/style.rs index 40529d06..48b13694 100644 --- a/iOverlay/src/mesh/int/style.rs +++ b/iOverlay/src/mesh/int/style.rs @@ -1,4 +1,5 @@ use super::arc::ArcOptions; +use super::math::backend::DEFAULT_MITER_MIN_TURN; use crate::mesh::math::MathMode; use i_float::int::angle::Angle; use i_float::int::number::int::IntNumber; @@ -9,10 +10,10 @@ pub enum IntLineJoin { #[default] Bevel, /// Clipped miter, limited by the minimum interior angle. - /// Integer math clamps the minimum interior angle to at least 5 degrees - /// and uses bevel joins for interior angles above 175 degrees. - /// Float math clamps the minimum to 0.01*pi (1.8 degrees), without the - /// near-straight bevel cutoff. Both clamp the maximum to one Angle unit + /// Integer math clamps the minimum interior angle to at least 5 degrees. + /// Float math clamps the minimum to 0.01*pi (1.8 degrees). + /// Both default to bevel joins for turns below 5 degrees; the style's + /// `miter_min_turn` configures this cutoff independently. Both clamp the maximum to one Angle unit /// below pi. The floating-point LineJoin adapter additionally clamps its /// input angle to 0.01*pi..=0.99*pi before conversion to this type. Miter(Angle), @@ -28,6 +29,11 @@ pub struct IntOutlineStyle { pub outer_offset: I, pub inner_offset: I, pub join: IntLineJoin, + /// Miter turns below this angle use bevel joins in both math modes. + /// Defaults to 5 degrees; clamped to 0..=pi during construction. + /// Zero disables the cutoff. Smaller values allow less stable intersections. + /// Ignored for bevel and round joins. + pub miter_min_turn: Angle, /// Arithmetic used to construct offsets and joins. pub math: MathMode, } @@ -38,6 +44,7 @@ impl IntOutlineStyle { outer_offset: offset, inner_offset: offset, join: IntLineJoin::Bevel, + miter_min_turn: Angle::from_bits(DEFAULT_MITER_MIN_TURN), math: MathMode::Integer, } } @@ -68,6 +75,12 @@ impl IntOutlineStyle { self.join = join; self } + + /// Sets the near-straight bevel cutoff, independently of the miter clipping angle. + pub fn miter_min_turn(mut self, angle: Angle) -> Self { + self.miter_min_turn = angle; + self + } } /// End caps for integer strokes. Custom points are local displacements in input @@ -87,6 +100,11 @@ pub struct IntStrokeStyle { pub start_cap: IntLineCap, pub end_cap: IntLineCap, pub join: IntLineJoin, + /// Miter turns below this angle use bevel joins in both math modes. + /// Defaults to 5 degrees; clamped to 0..=pi during construction. + /// Zero disables the cutoff. Smaller values allow less stable intersections. + /// Ignored for bevel and round joins. + pub miter_min_turn: Angle, /// Arithmetic for stroke construction. Float mode may change rounded output. pub math: MathMode, } @@ -98,6 +116,7 @@ impl IntStrokeStyle { start_cap: IntLineCap::Butt, end_cap: IntLineCap::Butt, join: IntLineJoin::Bevel, + miter_min_turn: Angle::from_bits(DEFAULT_MITER_MIN_TURN), math: MathMode::Integer, } } @@ -123,4 +142,10 @@ impl IntStrokeStyle { self.join = join; self } + + /// Sets the near-straight bevel cutoff, independently of the miter clipping angle. + pub fn miter_min_turn(mut self, angle: Angle) -> Self { + self.miter_min_turn = angle; + self + } } diff --git a/iOverlay/tests/miter_regression_tests.rs b/iOverlay/tests/miter_regression_tests.rs index e8101718..9c15a4d9 100644 --- a/iOverlay/tests/miter_regression_tests.rs +++ b/iOverlay/tests/miter_regression_tests.rs @@ -1,4 +1,9 @@ use i_float::int::{angle::Angle, point::IntPoint}; +use i_overlay::mesh::float::{ + outline::offset::OutlineOffset, + stroke::offset::StrokeOffset, + style::{LineJoin, OutlineStyle, StrokeStyle}, +}; use i_overlay::mesh::int::{ outline::offset::IntOutlineOffset, stroke::offset::IntStrokeOffset, @@ -53,7 +58,7 @@ fn nearly_collinear_shrink_outline_stays_inside_input_bounds() { } #[test] -fn only_integer_math_bevels_turns_below_five_degrees() { +fn both_math_modes_bevel_turns_below_five_degrees() { // Turns on either side of 5 degrees, for both traversal directions. for (dy, bevel_expected) in [(8_700, true), (8_800, false)] { let path = [ @@ -71,10 +76,37 @@ fn only_integer_math_bevels_turns_below_five_degrees() { false, ) .unwrap(); - assert_eq!( - miter == bevel, - math == MathMode::Integer && bevel_expected, - "dy={dy}, math={math:?}" + assert_eq!(miter == bevel, bevel_expected, "dy={dy}, math={math:?}"); + } + } + } +} + +#[test] +fn nearly_collinear_float_miter_stays_in_bounds_after_translation() { + let path = [ + IntPoint::new(-556_890_i32, -800_383), + IntPoint::new(0, 0), + IntPoint::new(1_670_672, 2_401_151), + ]; + let width = 200_000; + // The translated input passes validation, but the old Float intersection + // produced an out-of-range peak and panicked in debug builds. + for shift in [0, 1_071_140_673] { + let path = path.map(|p| IntPoint::new(p.x, p.y + shift)); + for math in [MathMode::Integer, MathMode::Float] { + let style = IntStrokeStyle::new(width) + .math(math) + .line_join(IntLineJoin::Miter(Angle::from_radians(3.0).unwrap())); + path.validate_stroke(&style).unwrap(); + let shapes = path.stroke(&style, false).unwrap(); + assert_eq!(shapes.len(), 1); + let x_bounds = path[0].x - width..=path[2].x + width; + let y_bounds = path[0].y - width..=path[2].y + width; + for point in shapes.iter().flatten().flatten() { + assert!( + x_bounds.contains(&point.x) && y_bounds.contains(&point.y), + "math={math:?}, shift={shift}, unexpected miter vertex {point:?}" ); } } @@ -106,3 +138,104 @@ fn only_integer_math_clips_corners_sharper_than_five_degrees() { ); } } + +#[test] +fn custom_miter_cutoff_applies_to_integer_stroke_and_outline() { + // The middle turn is about 2.86 degrees, between the two custom cutoffs. + let path = [ + IntPoint::new(-100_000_i32, 0), + IntPoint::new(0, 0), + IntPoint::new(100_000, 5_000), + ]; + let contour = [path[0], path[1], path[2], IntPoint::new(0, 100_000)]; + let angle = |degrees: f64| Angle::from_radians(degrees.to_radians()).unwrap(); + for math in [MathMode::Integer, MathMode::Float] { + let stroke = IntStrokeStyle::new(20_000) + .math(math) + .line_join(IntLineJoin::Miter(angle(10.0))); + let build_stroke = |cutoff| { + path.stroke(&stroke.clone().miter_min_turn(cutoff), false) + .unwrap() + }; + let low = build_stroke(angle(1.0)); + assert!(!low.is_empty()); + assert_eq!(low, build_stroke(angle(0.0))); + assert_ne!(low, build_stroke(angle(10.0))); + assert_eq!( + build_stroke(angle(10.0)), + path.stroke(&stroke.clone().line_join(IntLineJoin::Bevel), false) + .unwrap() + ); + assert_eq!( + build_stroke(Angle::from_bits(u32::MAX)), + build_stroke(angle(180.0)) + ); + + let outline = IntOutlineStyle::new(10_000) + .math(math) + .line_join(IntLineJoin::Miter(angle(10.0))); + let build_outline = |cutoff| contour.outline(&outline.miter_min_turn(cutoff)).unwrap(); + let low = build_outline(angle(1.0)); + assert!(!low.is_empty()); + assert_eq!(low, build_outline(angle(0.0))); + assert_ne!(low, build_outline(angle(10.0))); + } +} + +macro_rules! float_miter_cutoff { + ($name:ident, $scalar:ty) => { + #[test] + fn $name() { + let path = [ + [-100_000.0 as $scalar, 0.0], + [0.0, 0.0], + [100_000.0, 5_000.0], + ]; + let contour = [path[0], path[1], path[2], [0.0, 100_000.0]]; + let angle = |degrees: f64| degrees.to_radians() as $scalar; + for math in [MathMode::Integer, MathMode::Float] { + let stroke = StrokeStyle::new(20_000.0) + .math(math) + .line_join(LineJoin::Miter(angle(10.0))); + let build_stroke = |cutoff| { + path.stroke_fixed_scale(stroke.clone().miter_min_turn(cutoff), false, 1.0) + .unwrap() + }; + let low = build_stroke(angle(1.0)); + assert!(!low.is_empty()); + assert_eq!(low, build_stroke(0.0)); + assert_ne!(low, build_stroke(angle(10.0))); + assert_eq!( + build_stroke(angle(10.0)), + path.stroke_fixed_scale(stroke.clone().line_join(LineJoin::Bevel), false, 1.0) + .unwrap() + ); + assert_eq!(build_stroke(<$scalar>::NAN), build_stroke(angle(5.0))); + assert_eq!(build_stroke(<$scalar>::NEG_INFINITY), build_stroke(0.0)); + assert_eq!( + build_stroke(<$scalar>::INFINITY), + build_stroke(angle(180.0)) + ); + + let build_outline = |cutoff| { + contour + .outline_fixed_scale( + &OutlineStyle::new(10_000.0) + .math(math) + .line_join(LineJoin::Miter(angle(10.0))) + .miter_min_turn(cutoff), + 1.0, + ) + .unwrap() + }; + let low = build_outline(angle(1.0)); + assert!(!low.is_empty()); + assert_eq!(low, build_outline(0.0)); + assert_ne!(low, build_outline(angle(10.0))); + } + } + }; +} + +float_miter_cutoff!(custom_miter_cutoff_f32, f32); +float_miter_cutoff!(custom_miter_cutoff_f64, f64); From 95b678f3f4b9dd3d14465a7adbc9d50da3e07035 Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Sat, 19 Sep 2026 14:35:52 +0300 Subject: [PATCH 43/44] fix simplify for pos/neg winding rule --- iOverlay/src/core/simplify.rs | 37 +++---- iOverlay/tests/simplify_winding_tests.rs | 117 +++++++++++++++++++++++ iOverlay/tests/vector_tests.rs | 6 +- 3 files changed, 135 insertions(+), 25 deletions(-) create mode 100644 iOverlay/tests/simplify_winding_tests.rs diff --git a/iOverlay/src/core/simplify.rs b/iOverlay/src/core/simplify.rs index 23b066f1..ca6b85b5 100644 --- a/iOverlay/src/core/simplify.rs +++ b/iOverlay/src/core/simplify.rs @@ -130,30 +130,19 @@ where contour: &[IntPoint], ) -> ContourFillDirection { let contour_clockwise = contour.is_clockwise_ordered(); - let output_clockwise = output_direction == Clockwise; - - match fill_rule { - FillRule::EvenOdd | FillRule::NonZero => { - if contour_clockwise != output_clockwise { - ContourFillDirection::Reverse - } else { - ContourFillDirection::Correct - } - } - FillRule::Positive => { - if contour_clockwise == output_clockwise { - ContourFillDirection::Correct - } else { - ContourFillDirection::Empty - } - } - FillRule::Negative => { - if contour_clockwise != output_clockwise { - ContourFillDirection::Correct - } else { - ContourFillDirection::Empty - } - } + // Fill is determined by the input winding, independently of output orientation. + let filled = match fill_rule { + FillRule::EvenOdd | FillRule::NonZero => true, + FillRule::Positive => !contour_clockwise, + FillRule::Negative => contour_clockwise, + }; + + if !filled { + ContourFillDirection::Empty + } else if contour_clockwise != (output_direction == Clockwise) { + ContourFillDirection::Reverse + } else { + ContourFillDirection::Correct } } diff --git a/iOverlay/tests/simplify_winding_tests.rs b/iOverlay/tests/simplify_winding_tests.rs new file mode 100644 index 00000000..a56b2b8d --- /dev/null +++ b/iOverlay/tests/simplify_winding_tests.rs @@ -0,0 +1,117 @@ +use i_float::int::{number::int::IntNumber, point::IntPoint}; +use i_overlay::core::{ + fill_rule::FillRule, + integer::OverlayInt, + overlay::{ContourDirection, IntOverlayOptions, Overlay}, + overlay_rule::OverlayRule, + simplify::Simplify, +}; +use i_shape::{ + flat::buffer::FlatContoursBuffer, + int::{path::ContourExtension, shape::IntShapes}, +}; + +// Preserve orientation; only ignore which vertex starts a closed contour. +fn canonical(mut shapes: IntShapes) -> IntShapes { + for contour in shapes.iter_mut().flatten() { + let first = contour.iter().enumerate().min_by_key(|(_, p)| **p).unwrap().0; + contour.rotate_left(first); + } + shapes +} + +fn check_fill_and_output_direction + core::fmt::Debug>() { + let ccw = [(0_i16, 0_i16), (100, 0), (100, 100), (0, 100)] + .map(|(x, y)| IntPoint::new(I::from(x), I::from(y))) + .to_vec(); + for input_clockwise in [false, true] { + let mut contour = ccw.clone(); + if input_clockwise { + contour.reverse(); + } + let shape = vec![contour.clone()]; + let shapes = vec![shape.clone()]; + for rule in [ + FillRule::EvenOdd, + FillRule::NonZero, + FillRule::Positive, + FillRule::Negative, + ] { + let filled = match rule { + FillRule::EvenOdd | FillRule::NonZero => true, + FillRule::Positive => !input_clockwise, + FillRule::Negative => input_clockwise, + }; + for direction in [ContourDirection::CounterClockwise, ContourDirection::Clockwise] { + let options = IntOverlayOptions { + output_direction: direction, + ..Default::default() + }; + let expected = canonical( + Overlay::from_subj_custom(&shapes, options, Default::default()) + .overlay(OverlayRule::Subject, rule), + ); + assert_eq!(expected.len(), usize::from(filled)); + if filled { + assert_eq!( + expected[0][0].is_clockwise_ordered(), + direction == ContourDirection::Clockwise + ); + } + let check = |actual, entry| { + assert_eq!( + canonical(actual), + expected, + "entry={entry}, input_clockwise={input_clockwise}, rule={rule:?}, output={direction:?}" + ); + }; + + // The same single-contour fast path is reachable through all resource levels. + check(contour.simplify(rule, options), "contour resource"); + check(shape.simplify(rule, options), "shape resource"); + check(shapes.simplify(rule, options), "shapes resource"); + let mut flat = FlatContoursBuffer::default(); + flat.set_with_contour(&contour); + check(flat.simplify(rule, options), "flat resource"); + + let mut overlay = Overlay::new_custom(4, options, Default::default()); + let result = overlay.simplify_contour(&contour, rule); + assert_eq!( + result.is_none(), + filled && input_clockwise == (direction == ContourDirection::Clockwise), + "None must mean that the input contour needs no changes" + ); + check(result.unwrap_or_else(|| shapes.clone()), "simplify_contour"); + check( + overlay + .simplify_shape(&shape, rule) + .unwrap_or_else(|| shapes.clone()), + "simplify_shape", + ); + check(overlay.simplify_source(&shapes, rule), "simplify_source"); + overlay.simplify_flat_buffer(&mut flat, rule); + let actual = if flat.is_empty() { + vec![] + } else { + vec![flat.to_contours()] + }; + check(actual, "simplify_flat_buffer"); + } + } + } +} + +#[test] +fn simple_contour_winding_i16() { + check_fill_and_output_direction::(); +} + +#[test] +fn simple_contour_winding_i32() { + check_fill_and_output_direction::(); +} + +#[test] +fn simple_contour_winding_i64() { + check_fill_and_output_direction::(); +} diff --git a/iOverlay/tests/vector_tests.rs b/iOverlay/tests/vector_tests.rs index d4f14a72..486f9d07 100644 --- a/iOverlay/tests/vector_tests.rs +++ b/iOverlay/tests/vector_tests.rs @@ -41,7 +41,11 @@ mod tests { .collect(); assert_eq!( bottom, - vec![(0, 5, SUBJ_LEFT), (5, 10, SUBJ_LEFT | CLIP_LEFT), (10, 15, CLIP_LEFT)], + vec![ + (0, 5, SUBJ_LEFT), + (5, 10, SUBJ_LEFT | CLIP_LEFT), + (10, 15, CLIP_LEFT) + ], "redundant_vertex={redundant_vertex}" ); assert_eq!(shapes[0][0].len(), 8); From 96f7adc1481f0f2e6ba2085ac7d086be8f4276ad Mon Sep 17 00:00:00 2001 From: Nail Sharipov Date: Sat, 19 Sep 2026 17:31:45 +0300 Subject: [PATCH 44/44] fix clippy and up version --- iOverlay/Cargo.toml | 18 +++++++-------- iOverlay/src/core/simplify.rs | 13 ++++++----- iOverlay/src/float/hierarchy.rs | 4 ++-- iOverlay/src/mesh/int/join.rs | 1 + iOverlay/src/mesh/int/stroke/bounds.rs | 8 +++---- iOverlay/tests/crash_tests.rs | 8 +++---- iOverlay/tests/float_point_adapter.rs | 2 +- iOverlay/tests/int_outline_tests.rs | 10 +++++---- iOverlay/tests/int_resource_tests.rs | 2 +- iOverlay/tests/integer_range_tests.rs | 22 ++++++++++++++----- iOverlay/tests/outline_cell_oracle_tests.rs | 8 +++---- iOverlay/tests/overlay_tests.rs | 2 +- iOverlay/tests/predicate_cell_oracle_tests.rs | 5 ++++- iOverlay/tests/slice_area_tests.rs | 18 ++++++++++----- iOverlay/tests/variable_stroke_stress.rs | 2 +- 15 files changed, 73 insertions(+), 50 deletions(-) diff --git a/iOverlay/Cargo.toml b/iOverlay/Cargo.toml index 9263c2b2..c06a6390 100644 --- a/iOverlay/Cargo.toml +++ b/iOverlay/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "i_overlay" -version = "8.1.2" +version = "9.0.0" authors = ["Nail Sharipov "] edition = "2024" rust-version = "1.88" @@ -15,11 +15,11 @@ categories = ["algorithms", "graphics", "science::geo", "mathematics", "no-std"] i_tree = { version = "^0.19.0" } i_key_sort = { version = "^0.11.0" } -#i_float = { version = "^4.1.0"} -#i_shape = { version = "^4.0.0"} +i_float = { version = "^5.0.0"} +i_shape = { version = "^5.0.0"} -i_float = { path = "../../iFloat"} -i_shape = { path = "../../iShape"} +#i_float = { path = "../../iFloat"} +#i_shape = { path = "../../iShape"} #i_tree = { path = "../../iTree" } #i_key_sort = { path = "../../iKeySort" } @@ -38,7 +38,7 @@ variable_stroke_debug = [] serde = { version = "^1.0", features = ["derive"] } serde_json = "^1.0" rand = { version = "~0.10", features = ["alloc"] } -#i_float = { version = "^4.1.0", features = ["serde"] } -#i_shape = { version = "^4.0.0", features = ["serde"] } -i_float = { path = "../../iFloat", features = ["serde"] } -i_shape = { path = "../../iShape", features = ["serde"] } +i_float = { version = "^5.0.0", features = ["serde"] } +i_shape = { version = "^5.0.0", features = ["serde"] } +#i_float = { path = "../../iFloat", features = ["serde"] } +#i_shape = { path = "../../iShape", features = ["serde"] } diff --git a/iOverlay/src/core/simplify.rs b/iOverlay/src/core/simplify.rs index ca6b85b5..e5fc1a74 100644 --- a/iOverlay/src/core/simplify.rs +++ b/iOverlay/src/core/simplify.rs @@ -66,12 +66,13 @@ where fill_rule: FillRule, ) -> IntShapes { let mut paths = resource.iter_paths(); - if let Some(contour) = paths.next() { - if paths.next().is_none() && self.options.min_output_area == I::WideUInt::ZERO { - return self - .simplify_contour(contour, fill_rule) - .unwrap_or_else(|| vec![vec![contour.to_vec()]]); - } + if let Some(contour) = paths.next() + && paths.next().is_none() + && self.options.min_output_area == I::WideUInt::ZERO + { + return self + .simplify_contour(contour, fill_rule) + .unwrap_or_else(|| vec![vec![contour.to_vec()]]); } self.reinit_with_subj(resource); self.overlay(OverlayRule::Subject, fill_rule) diff --git a/iOverlay/src/float/hierarchy.rs b/iOverlay/src/float/hierarchy.rs index 94d0587c..68a60014 100644 --- a/iOverlay/src/float/hierarchy.rs +++ b/iOverlay/src/float/hierarchy.rs @@ -227,8 +227,8 @@ mod tests { IntPoint::new(10, 10), IntPoint::new(0, 10), ], - contour_ranges: vec![0..4], - shape_ranges: vec![0..1], + contour_ranges: core::iter::once(0..4).collect(), + shape_ranges: core::iter::once(0..1).collect(), }, links: vec![], }; diff --git a/iOverlay/src/mesh/int/join.rs b/iOverlay/src/mesh/int/join.rs index 4fd29ec4..bae91deb 100644 --- a/iOverlay/src/mesh/int/join.rs +++ b/iOverlay/src/mesh/int/join.rs @@ -59,6 +59,7 @@ impl> Join { } /// Joins the two outward offset rays, in boundary traversal order. + #[allow(clippy::too_many_arguments)] pub(super) fn add( &mut self, center: IntPoint, diff --git a/iOverlay/src/mesh/int/stroke/bounds.rs b/iOverlay/src/mesh/int/stroke/bounds.rs index 6ed28731..8cc6b100 100644 --- a/iOverlay/src/mesh/int/stroke/bounds.rs +++ b/iOverlay/src/mesh/int/stroke/bounds.rs @@ -14,10 +14,10 @@ use i_shape::source::int::resource::IntShapeResource; pub(super) trait StrokeBounds: IntShapeResource { fn validate_stroke_bounds(&self, style: &IntStrokeStyle) -> Result<(), IntStrokeError> { - if let Some(rect) = IntRect::with_iter(self.iter_paths().flatten()) { - if !crate::mesh::int::bounds::expanded_is_safe(rect, stroke_padding(style)) { - return Err(IntStrokeError::CoordinateOutOfRange); - } + if let Some(rect) = IntRect::with_iter(self.iter_paths().flatten()) + && !crate::mesh::int::bounds::expanded_is_safe(rect, stroke_padding(style)) + { + return Err(IntStrokeError::CoordinateOutOfRange); } Ok(()) } diff --git a/iOverlay/tests/crash_tests.rs b/iOverlay/tests/crash_tests.rs index 62e0b939..f9f3197e 100644 --- a/iOverlay/tests/crash_tests.rs +++ b/iOverlay/tests/crash_tests.rs @@ -53,10 +53,10 @@ mod tests { #[test] fn test_01() { let subj = [ - [-117.04171489206965, 1820.3621519926919], - [4619.6817058891429, -2133.11539650432], - [1902.5599837294722, -133.53167784432389], - [-3572.1275050425684, 3909.4677532724309], + [-117.04171489206965, 1_820.362_151_992_692], + [4_619.681_705_889_143, -2133.11539650432], + [1902.5599837294722, -133.531_677_844_323_9], + [-3572.1275050425684, 3_909.467_753_272_431], [3047.0491344383845, -4087.6336157702817], ]; diff --git a/iOverlay/tests/float_point_adapter.rs b/iOverlay/tests/float_point_adapter.rs index 9200df8f..b094332c 100644 --- a/iOverlay/tests/float_point_adapter.rs +++ b/iOverlay/tests/float_point_adapter.rs @@ -63,7 +63,7 @@ mod tests { ) .unwrap(); - let adapter_100 = FloatPointAdapter::<_, i32>::with_scale(buffer_rect.clone(), 100.0); + let adapter_100 = FloatPointAdapter::<_, i32>::with_scale(buffer_rect, 100.0); let adapter_1000 = FloatPointAdapter::<_, i32>::with_scale(buffer_rect, 1000.0); let subj_100 = FloatOverlay::with_adapter(adapter_100, shape.len()) diff --git a/iOverlay/tests/int_outline_tests.rs b/iOverlay/tests/int_outline_tests.rs index 6b3e56cb..4a897f75 100644 --- a/iOverlay/tests/int_outline_tests.rs +++ b/iOverlay/tests/int_outline_tests.rs @@ -92,8 +92,10 @@ fn area_filter_runs_after_union() { let style = IntOutlineStyle::new(0); let expected = source.outline(&style).unwrap(); assert_eq!(expected.area(), 12 * 1024 * 1024); - let mut options = IntOverlayOptions::default(); - options.min_output_area = 10 * 1024 * 1024; + let mut options = IntOverlayOptions { + min_output_area: 10 * 1024 * 1024, + ..Default::default() + }; assert_eq!(source.outline_custom(&style, options).unwrap(), expected); options.min_output_area = 13 * 1024 * 1024; assert!(source.outline_custom(&style, options).unwrap().is_empty()); @@ -108,8 +110,8 @@ fn resource_forms_and_flat_output_agree() { contours.set_with_shapes(&shapes); let flat_shapes = FlatShapesBuffer { points: path.clone(), - contour_ranges: vec![0..4], - shape_ranges: vec![0..1], + contour_ranges: core::iter::once(0..4).collect(), + shape_ranges: core::iter::once(0..1).collect(), }; let style = IntOutlineStyle::new(1024); let expected = path.outline(&style).unwrap(); diff --git a/iOverlay/tests/int_resource_tests.rs b/iOverlay/tests/int_resource_tests.rs index a3fee9f1..74b034ff 100644 --- a/iOverlay/tests/int_resource_tests.rs +++ b/iOverlay/tests/int_resource_tests.rs @@ -46,7 +46,7 @@ fn check_engines() { let mut flat_contours = FlatContoursBuffer::default(); flat_contours.set_with_shape(&contours); let mut flat_shapes = FlatShapesBuffer::default(); - flat_shapes.set_with_shapes(&vec![vec![clip.to_vec()]]); + flat_shapes.set_with_shapes(&[vec![clip.to_vec()]]); for rule in [ OverlayRule::Union, OverlayRule::Intersect, diff --git a/iOverlay/tests/integer_range_tests.rs b/iOverlay/tests/integer_range_tests.rs index 8e36aa32..4511bc77 100644 --- a/iOverlay/tests/integer_range_tests.rs +++ b/iOverlay/tests/integer_range_tests.rs @@ -82,7 +82,7 @@ fn boundaries + Into>() { let hi = half - 1; let outer = square(lo, hi); check::( - &[outer.clone()], + core::slice::from_ref(&outer), &[], OverlayRule::Subject, vec![vec![outer.clone()]], @@ -91,17 +91,22 @@ fn boundaries + Into>() { // 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]]); + check::( + core::slice::from_ref(&small), + &[], + OverlayRule::Subject, + vec![vec![small.clone()]], + ); } let inner = square(-half / 2, half / 2); let mut hole = inner.clone(); hole.reverse(); check::( - &[outer.clone()], + core::slice::from_ref(&outer), &[inner], OverlayRule::Difference, - vec![vec![outer, hole]], + vec![vec![outer.clone(), hole]], ); // Issue #88's steep edge, stretched to the maximum supported span. @@ -185,13 +190,18 @@ fn i64_boundaries() { fn issue_88_with_a_wider_engine_or_rescaled_coordinates() { let triangle = vec![[0, 0], [129, -23169], [0, 9854]]; check::( - &[triangle.clone()], + core::slice::from_ref(&triangle), &[], 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]]); + check::( + core::slice::from_ref(&scaled), + &[], + OverlayRule::Subject, + vec![vec![scaled.clone()]], + ); } #[test] diff --git a/iOverlay/tests/outline_cell_oracle_tests.rs b/iOverlay/tests/outline_cell_oracle_tests.rs index 705e7f1d..d5c63eb1 100644 --- a/iOverlay/tests/outline_cell_oracle_tests.rs +++ b/iOverlay/tests/outline_cell_oracle_tests.rs @@ -29,11 +29,11 @@ fn orthogonal_miter_offsets_match_cell_dilation_and_erosion() { for case in 0..200 { let mut cells = [[false; 6]; 6]; let mut contours = Vec::new(); - for x in 0..6 { - for y in 0..6 { + for (x, column) in cells.iter_mut().enumerate() { + for (y, cell) in column.iter_mut().enumerate() { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); - cells[x][y] = (seed >> 32) % 100 < 20 + case % 65; - if cells[x][y] { + *cell = (seed >> 32) % 100 < 20 + case % 65; + if *cell { let (x, y) = (4 * x as i32, 4 * y as i32); contours.push(vec![ IntPoint::new(x, y), diff --git a/iOverlay/tests/overlay_tests.rs b/iOverlay/tests/overlay_tests.rs index 426aff60..984b8564 100644 --- a/iOverlay/tests/overlay_tests.rs +++ b/iOverlay/tests/overlay_tests.rs @@ -105,7 +105,7 @@ mod tests { let graph = overlay.build_graph_view(fill_rule).unwrap(); let result = graph.extract_shapes(overlay_rule, &mut Default::default()); - println!("{}: {}", &overlay_rule, result.json_print()); + println!("{}: {}", overlay_rule, result.json_print()); match overlay_rule { OverlayRule::Subject => { assert_eq!(true, overlay::is_group_of_shapes_one_of(&result, &test.subject)) diff --git a/iOverlay/tests/predicate_cell_oracle_tests.rs b/iOverlay/tests/predicate_cell_oracle_tests.rs index 355802c3..62309825 100644 --- a/iOverlay/tests/predicate_cell_oracle_tests.rs +++ b/iOverlay/tests/predicate_cell_oracle_tests.rs @@ -14,7 +14,10 @@ fn filled(count: i32, rule: FillRule) -> bool { } } -fn closure(cells: &BTreeSet<(i32, i32)>) -> (BTreeSet<(i32, i32)>, BTreeSet<(i32, i32, i32, i32)>) { +type Vertices = BTreeSet<(i32, i32)>; +type Edges = BTreeSet<(i32, i32, i32, i32)>; + +fn closure(cells: &BTreeSet<(i32, i32)>) -> (Vertices, Edges) { let mut vertices = BTreeSet::new(); let mut edges = BTreeSet::new(); for &(x, y) in cells { diff --git a/iOverlay/tests/slice_area_tests.rs b/iOverlay/tests/slice_area_tests.rs index 6e823c28..0cea5566 100644 --- a/iOverlay/tests/slice_area_tests.rs +++ b/iOverlay/tests/slice_area_tests.rs @@ -40,8 +40,10 @@ fn slice_minimum_area_removes_small_piece() { (90, vec![180]), (91, vec![]), ] { - let mut options = IntOverlayOptions::default(); - options.min_output_area = threshold; + let options = IntOverlayOptions { + min_output_area: threshold, + ..Default::default() + }; let filtered = graph.extract_shapes_custom(StringRule::Slice, options); let mut areas: Vec<_> = filtered.iter().map(|s| area_two(&s[0])).collect(); areas.sort(); @@ -53,8 +55,10 @@ fn slice_minimum_area_removes_small_piece() { fn slice_minimum_area_above_subject_area_returns_empty() { let mut overlay = StringOverlay::from_shape(&square()); let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); - let mut options = IntOverlayOptions::default(); - options.min_output_area = 1000; + let options = IntOverlayOptions { + min_output_area: 1000, + ..Default::default() + }; let result = graph.extract_shapes_custom(StringRule::Slice, options); assert!( result.is_empty(), @@ -79,8 +83,10 @@ fn slice_small_area_threshold_preserves_large_loops_and_holes() { let total_area: i64 = expected.iter().flatten().map(|p| area_two(p)).sum(); assert_eq!(total_area, 200); for threshold in [1, 16] { - let mut options = IntOverlayOptions::default(); - options.min_output_area = threshold; + let options = IntOverlayOptions { + min_output_area: threshold, + ..Default::default() + }; let actual = graph.extract_shapes_custom(StringRule::Slice, options); assert_eq!( actual, expected, diff --git a/iOverlay/tests/variable_stroke_stress.rs b/iOverlay/tests/variable_stroke_stress.rs index bcce13d5..7cf6d19a 100644 --- a/iOverlay/tests/variable_stroke_stress.rs +++ b/iOverlay/tests/variable_stroke_stress.rs @@ -82,7 +82,7 @@ fn random_variable_stroke_path(rng: &mut StressRng) -> Vec