diff --git a/cavalier_contours/Cargo.toml b/cavalier_contours/Cargo.toml index 09cedc9..2c2e9da 100644 --- a/cavalier_contours/Cargo.toml +++ b/cavalier_contours/Cargo.toml @@ -12,7 +12,6 @@ version = "0.3.0" [features] allow_unsafe = [] # feature has no explicit dependencies -default = ["serde"] [dependencies] num-traits = "0.2" diff --git a/cavalier_contours/src/shape_algorithms/mod.rs b/cavalier_contours/src/shape_algorithms/mod.rs index a28c23d..14cf1db 100644 --- a/cavalier_contours/src/shape_algorithms/mod.rs +++ b/cavalier_contours/src/shape_algorithms/mod.rs @@ -14,14 +14,22 @@ use crate::{ }, }; -pub struct OffsetLoop { +struct OffsetLoop { pub parent_loop_idx: usize, pub indexed_pline: IndexedPolyline, } -pub struct ClosedPlineSet { - pub ccw_loops: Vec>, - pub cw_loops: Vec>, +impl Default for OffsetLoop +where + T: Real, +{ + #[inline] + fn default() -> Self { + Self { + parent_loop_idx: Default::default(), + indexed_pline: IndexedPolyline::new(Polyline::new()), + } + } } #[derive(Debug, Clone)] @@ -42,78 +50,87 @@ where } } - fn parallel_offset(&self, offset: T) -> Vec> { + fn parallel_offset_for_shape( + &self, + offset: T, + options: &ShapeOffsetOptions, + ) -> Vec> { let opts = PlineOffsetOptions { aabb_index: Some(&self.spatial_index), handle_self_intersects: false, - ..Default::default() + pos_equal_eps: options.pos_equal_eps, + slice_join_eps: options.slice_join_eps, + offset_dist_eps: options.offset_dist_eps, }; self.polyline.parallel_offset_opt(offset, &opts) } } -pub trait ShapeSource { - type Num: Real; - type OutputPolyline; - type Loop: PlineSource; - fn ccw_loop_count(&self) -> usize; - fn cw_loop_count(&self) -> usize; - fn get_loop(&self, i: usize) -> &Self::Loop; -} - -pub trait ShapeIndex { - type Num: Real; - fn get_loop_index(&self, i: usize) -> Option<&StaticAABB2DIndex>; -} - -impl ShapeIndex for Vec> -where - T: Real, -{ - type Num = T; - fn get_loop_index(&self, i: usize) -> Option<&StaticAABB2DIndex> { - self.get(i) - } -} - +/// Shape represented by positive area counter clockwise polylines, `ccw_plines` and negative/hole +/// area clockwise polylines, `cw_plines`. #[derive(Debug, Clone)] pub struct Shape { + /// Positive/filled area counter clockwise polylines. pub ccw_plines: Vec>, + /// Negative/hole area clockwise polylines. pub cw_plines: Vec>, + /// Spatial index of all the polyline area bounding boxes, index positions correspond to in + /// order all the counter clockwise polylines followed by all the clockwise polylines. E.g., if + /// there is 1 `ccw_plines` and 2 `cw_plines` then index position 0 is the bounding box for the + /// ccw pline and index positions 1 and 2 correspond to the first and second cw plines. pub plines_index: StaticAABB2DIndex, } +/// Struct to hold options parameters when performing shape offset. #[derive(Debug, Clone)] -pub struct DebugShape { - pub slice_point_sets: Vec>, - pub slice_count: usize, - pub slice_start_pts: Vec>, +pub struct ShapeOffsetOptions { + /// Fuzzy comparison epsilon used for determining if two positions are equal. + pub pos_equal_eps: T, + /// Fuzzy comparison epsilon used when testing distance of slices to original polyline for + /// validity. + pub offset_dist_eps: T, + /// Fuzzy comparison epsilon used for determining if two positions are equal when stitching + /// polyline slices together. + pub slice_join_eps: T, } -impl Shape +impl ShapeOffsetOptions where T: Real, { - fn get_loop<'a>( - i: usize, - s1: &'a [OffsetLoop], - s2: &'a [OffsetLoop], - ) -> &'a OffsetLoop { - if i < s1.len() { - &s1[i] - } else { - &s2[i - s1.len()] + #[inline] + pub fn new() -> Self { + Self { + pos_equal_eps: T::from(1e-5).unwrap(), + offset_dist_eps: T::from(1e-4).unwrap(), + slice_join_eps: T::from(1e-4).unwrap(), } } +} +impl Default for ShapeOffsetOptions +where + T: Real, +{ + #[inline] + fn default() -> Self { + Self::new() + } +} + +impl Shape +where + T: Real, +{ pub fn from_plines(plines: I) -> Self where I: IntoIterator>, { let mut ccw_plines = Vec::new(); let mut cw_plines = Vec::new(); - for pl in plines.into_iter() { + // skip empty polylines + for pl in plines.into_iter().filter(|p| p.vertex_count() > 1) { if pl.orientation() == PlineOrientation::CounterClockwise { ccw_plines.push(IndexedPolyline::new(pl)); } else { @@ -148,35 +165,59 @@ where } } - pub fn parallel_offset(&self, offset: T) -> Option<(Self, DebugShape)> { - // TODO: make part of options parameter. - let pos_equal_eps = T::from(1e-5).unwrap(); - let offset_tol = T::from(1e-4).unwrap(); - let slice_join_eps = T::from(1e-4).unwrap(); + /// Return an empty shape (0 polylines). + #[inline] + pub fn empty() -> Self { + Self { + ccw_plines: Vec::new(), + cw_plines: Vec::new(), + plines_index: StaticAABB2DIndexBuilder::new(0).build().unwrap(), + } + } + + pub fn parallel_offset(&self, offset: T, options: ShapeOffsetOptions) -> Self { + let pos_equal_eps = options.pos_equal_eps; + let offset_dist_eps = options.offset_dist_eps; + let slice_join_eps = options.slice_join_eps; + // generate offset loops let mut ccw_offset_loops = Vec::new(); let mut cw_offset_loops = Vec::new(); let mut parent_idx = 0; for pline in self.ccw_plines.iter() { - for offset_pline in pline.parallel_offset(offset) { - // must check if orientation inverted (due to collapse of very narrow or small input) - if offset_pline.area() < T::zero() { + for offset_pline in pline.parallel_offset_for_shape(offset, &options) { + // check if orientation inverted (due to collapse of very narrow or small input) + // skip if inversion happened (ccw became cw while offsetting inward) + let area = offset_pline.area(); + if offset > T::zero() && area < T::zero() { continue; } - let ccw_offset_loop = OffsetLoop { + let offset_loop = OffsetLoop { parent_loop_idx: parent_idx, indexed_pline: IndexedPolyline::new(offset_pline), }; - ccw_offset_loops.push(ccw_offset_loop); + + if area < T::zero() { + cw_offset_loops.push(offset_loop); + } else { + ccw_offset_loops.push(offset_loop); + } } parent_idx += 1; } for pline in self.cw_plines.iter() { - for offset_pline in pline.parallel_offset(offset) { + for offset_pline in pline.parallel_offset_for_shape(offset, &options) { let area = offset_pline.area(); + + // check if orientation inverted (due to collapse of very narrow or small input) + // skip if inversion happened (cw became ccw while offsetting inward) + if offset < T::zero() && area > T::zero() { + continue; + } + let offset_loop = OffsetLoop { parent_loop_idx: parent_idx, indexed_pline: IndexedPolyline::new(offset_pline), @@ -194,7 +235,7 @@ where let offset_loop_count = ccw_offset_loops.len() + cw_offset_loops.len(); if offset_loop_count == 0 { // no offsets remaining - return None; + return Self::empty(); } // build spatial index of offset loop approximate bounding boxes @@ -260,8 +301,7 @@ where let intrs_opts = FindIntersectsOptions { pline1_aabb_index: Some(spatial_idx1), - // TODO: Use option parameter - pline offset needs to be updated as well? - ..Default::default() + pos_equal_eps, }; let intersects = loop1 @@ -361,7 +401,7 @@ where midpoint, query_stack, pos_equal_eps, - offset_tol, + offset_dist_eps, ) { return false; } @@ -469,13 +509,14 @@ where curr_loop.parent_loop_idx, &mut query_stack, ) { - // TODO: for now just cloning polylines to result to avoid complexity - if curr_loop.indexed_pline.polyline.orientation() - == PlineOrientation::CounterClockwise - { - ccw_plines_result.push(curr_loop.indexed_pline.clone()); + // Take/consume the loop to avoid allocation and copy required to clone + if loop_idx < ccw_offset_loops.len() { + let r = std::mem::take(&mut ccw_offset_loops[loop_idx]).indexed_pline; + ccw_plines_result.push(r); } else { - cw_plines_result.push(curr_loop.indexed_pline.clone()) + let i = loop_idx - ccw_offset_loops.len(); + let r = std::mem::take(&mut cw_offset_loops[i]).indexed_pline; + cw_plines_result.push(r) } } } @@ -589,28 +630,26 @@ where b.build().unwrap() }; - let d = DebugShape { - slice_point_sets, - slice_count: slices_data.len(), - slice_start_pts: slices_data - .iter() - .map(|s| s.v_data.updated_start.pos()) - .collect(), - }; + Shape { + ccw_plines: ccw_plines_result, + cw_plines: cw_plines_result, + plines_index, + } + } - Some(( - Shape { - ccw_plines: ccw_plines_result, - cw_plines: cw_plines_result, - plines_index, - }, - d, - )) + fn get_loop<'a>( + i: usize, + s1: &'a [OffsetLoop], + s2: &'a [OffsetLoop], + ) -> &'a OffsetLoop { + if i < s1.len() { + &s1[i] + } else { + &s2[i - s1.len()] + } } } -// fn stitch_slices_into_closed_polylines< - // intersects between two offset loops #[derive(Debug, Clone)] pub struct SlicePointSet { @@ -630,27 +669,3 @@ struct DissectedSlice { source_idx: usize, v_data: PlineViewData, } - -// fn create_offset_loops(input_set: &ClosedPlineSet, abs_offset: T) -// where -// T: Real, -// { -// let mut result = ClosedPlineSet { -// ccw_loops: Vec::new(), -// cw_loops: Vec::new(), -// }; - -// let mut parent_idx = 0; -// for pline in input_set.ccw_loops.iter() { -// for offset_pline in pline.parallel_offset(abs_offset) { -// // must check if orientation inverted (due to collapse of very narrow or small input) -// if offset_pline.area() < T::zero() { -// continue; -// } - -// let spatial_index = offset_pline.create_approx_aabb_index(); -// } -// } - -// result -// } diff --git a/cavalier_contours/tests/test_multi_pline_parallel_offset.rs b/cavalier_contours/tests/test_multi_pline_parallel_offset.rs deleted file mode 100644 index c707f18..0000000 --- a/cavalier_contours/tests/test_multi_pline_parallel_offset.rs +++ /dev/null @@ -1,65 +0,0 @@ -use cavalier_contours::{polyline::Polyline, shape_algorithms::Shape}; - -#[test] -fn test1() { - let json = r#"[ - { - "isClosed": true, - "vertexes": [ - [100, 100, -0.5], - [80, 90, 0.374794619217547], - [210, 0, 0], - [230, 0, 1], - [320, 0, -0.5], - [280, 0, 0.5], - [390, 210, 0], - [280, 120, 0.5] - ] - }, - { - "isClosed": true, - "vertexes": [ - [150, 50, 0], - [146.32758944101474, 104.13867601941358, 0], - [200, 100, 0], - [200, 50, 0] - ] - } -]"#; - - let plines: Vec> = serde_json::from_str(json).unwrap(); - let shape = Shape::from_plines(plines); - let result = shape.parallel_offset(17.0).unwrap(); -} - -#[test] -fn test2() { - let json = r#"[ - { - "isClosed": true, - "vertexes": [ - [160.655879768138, 148.75471430537402, -0.5], - [80, 90, 0.374794619217547], - [210, 0, 0], - [230, 0, 1], - [320, 0, -0.5], - [280, 0, 0.5], - [390, 210, 0], - [280, 120, 0.5] - ] - }, - { - "isClosed": true, - "vertexes": [ - [150, 50, 0], - [192.62381977774953, 130.82800839110848, 0], - [200, 100, 0], - [200, 50, 0] - ] - } -]"#; - - let plines: Vec> = serde_json::from_str(json).unwrap(); - let shape = Shape::from_plines(plines); - let result = shape.parallel_offset(17.0).unwrap(); -} diff --git a/cavalier_contours/tests/test_pline_parallel_offset.rs b/cavalier_contours/tests/test_pline_parallel_offset.rs index 167cc89..3067038 100644 --- a/cavalier_contours/tests/test_pline_parallel_offset.rs +++ b/cavalier_contours/tests/test_pline_parallel_offset.rs @@ -111,6 +111,10 @@ mod test_simple { use cavalier_contours::{pline_closed, pline_open}; declare_offset_tests!( + empty_returns_empty { + (Polyline::::new(), 5.0) => + [] + } circle_collapsed_into_point { (pline_closed![ (0.0, 0.0, 1.0), (2.0, 0.0, 1.0)], 1.0) => [] diff --git a/cavalier_contours/tests/test_shape_parallel_offset.rs b/cavalier_contours/tests/test_shape_parallel_offset.rs new file mode 100644 index 0000000..3a77d55 --- /dev/null +++ b/cavalier_contours/tests/test_shape_parallel_offset.rs @@ -0,0 +1,76 @@ +mod test_utils; + +use cavalier_contours::{polyline::Polyline, shape_algorithms::Shape}; +use test_utils::{create_property_set, PlineProperties}; + +use crate::test_utils::property_sets_match; + +fn run_shape_offset_tests(input: I, offset: f64, expected_properties_set: &[PlineProperties]) +where + I: IntoIterator, +{ + let s = Shape::from_plines(input); + let result = s.parallel_offset(offset, Default::default()); + let plines = result + .ccw_plines + .iter() + .chain(result.cw_plines.iter()) + .map(|p| &p.polyline); + let result_properties = create_property_set(plines, false); + + assert!( + property_sets_match(&result_properties, expected_properties_set), + "result property sets do not match" + ) +} + +macro_rules! declare_offset_tests { + ($($name:ident { $($value:expr => $expected:expr),+ $(,)? })*) => { + $( + #[test] + fn $name() { + $( + run_shape_offset_tests($value.0, $value.1, &$expected); + )+ + } + )+ + }; +} +mod test_simple { + use super::*; + use cavalier_contours::pline_closed; + + declare_offset_tests!( + empty_returns_empty { + (Vec::::new(), 5.0) => [] + } + set_of_empty_returns_empty { + ([Polyline::::new_closed(), Polyline::new_closed()], 5.0) => [] + } + rectangle_inside_shape { + ([pline_closed![(100.0, 100.0, -0.5), (80.0, 90.0, 0.374794619217547), (210.0, 0.0, 0.0), (230.0, 0.0, 1.0), (320.0, 0.0, -0.5), (280.0, 0.0, 0.5), (390.0, 210.0, 0.0), (280.0, 120.0, 0.5)], + pline_closed![(150.0, 50.0, 0.0), (150.0, 100.0, 0.0), (200.0, 100.0, 0.0), (200.0, 50.0, 0.0)]], 3.0) => + [PlineProperties::new(12, 40977.79061358948, 998.5536075336107, 84.32384698504309, -41.99999999999997, 401.41586988912127, 205.22199935960901), + PlineProperties::new(8, -3128.274333882308, 218.84955592153878, 147.0, 47.0, 203.0, 103.0)] + } + ); +} + +mod test_specific { + use super::*; + use cavalier_contours::pline_closed; + + declare_offset_tests!( + case1 { + ([pline_closed![(100.0, 100.0, -0.5), (80.0, 90.0, 0.374794619217547), (210.0, 0.0, 0.0), (230.0, 0.0, 1.0), (320.0, 0.0, -0.5), (280.0, 0.0, 0.5), (390.0, 210.0, 0.0), (280.0, 120.0, 0.5)], + pline_closed![(150.0, 50.0, 0.0), (146.32758944101474, 104.13867601941358, 0.0), (200.0, 100.0, 0.0), (200.0, 50.0, 0.0)]], 17.0) => + [PlineProperties::new(22, 20848.93377998434, 1149.2701898185926, 102.79564651409214, -28.000000000000004, 387.41586988912127, 181.8843855860552)] + } + case2 { + ([pline_closed![(160.655879768138, 148.75471430537402, -0.5), (80.0, 90.0, 0.374794619217547), (210.0, 0.0, 0.0), (230.0, 0.0, 1.0), (320.0, 0.0, -0.5), (280.0, 0.0, 0.5), (390.0, 210.0, 0.0), (280.0, 120.0, 0.5)], + pline_closed![(150.0, 50.0, 0.0), (192.62381977774953, 130.82800839110848, 0.0), (200.0, 100.0, 0.0), (200.0, 50.0, 0.0)]], 17.0) => + [PlineProperties::new(20, 20135.256681247833, 1053.2414865948808, 105.64684517241575, -28.000000000000004, 387.41586988912127, 181.8843855860552), + PlineProperties::new(4, 2.091291658768, 9.557331573939933, 176.64810774674345, 136.97815392110508, 178.9335673169721, 140.906549335123)] + } + ); +}