diff --git a/crates/pathops/src/offset.rs b/crates/pathops/src/offset.rs index bf533476be..f9579f1bf3 100644 --- a/crates/pathops/src/offset.rs +++ b/crates/pathops/src/offset.rs @@ -126,21 +126,49 @@ fn path_is_finite(path: &PathData) -> bool { /// Fallible [`offset_path`] that returns an error when the input is non-finite or exceeds /// the complexity bound ([`MAX_OFFSET_SEGMENT_PAIRS`]), or when planar arrangement fails. pub fn try_offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) -> Result { - let src = std::cell::LazyCell::new(|| SegmentTree::new(boxed_segments(&path.to_bezpath()))); - offset_with_check(path, delta, join, miter_limit, |p, r| any_within(&src, p, r)) + offset_with_check(path, delta, join, miter_limit, |part| { + let bp = part.to_bezpath(); + let src = std::cell::LazyCell::new(move || SegmentTree::new(boxed_segments(&bp))); + move |p, r| any_within(&src, p, r) + }) } -/// [`try_offset_path`] with the test of whether some segment of `path` lies closer than a radius to a point. -fn offset_with_check( +/// [`try_offset_path`] with `within_of(part)`, the test of whether some segment of `part` lies +/// closer than a radius to a point. +fn offset_with_check bool>( + path: &PathData, + delta: f64, + join: Join, + miter_limit: f64, + within_of: impl Fn(&PathData) -> W, +) -> Result { + if !delta.is_finite() || !path_is_finite(path) { + return Err(PathOpsError::NonFinite); + } + // An inset keeps only faces inside the closed fill, so it would drop the outline of every + // open subpath (#6427). Inset the closed subpaths, outline the open ones as a positive offset + // does, and unite the two. + if delta < 0.0 && path.subpaths.iter().any(|s| s.anchors.len() > 1 && !close_if_ends_meet(s).closed) { + let (closed, open): (Vec, Vec) = path.subpaths.iter().cloned().partition(|s| close_if_ends_meet(s).closed); + let (closed, open) = (PathData::new(closed), PathData::new(open)); + let inset = offset_region(&closed, delta, join, miter_limit, within_of(&closed))?; + let outline = offset_region(&open, -delta, join, miter_limit, within_of(&open))?; + if inset.is_empty() { + return Ok(outline); + } + return crate::boolean::try_unite_all(&[(&inset, FillRule::NonZero), (&outline, FillRule::NonZero)]); + } + offset_region(path, delta, join, miter_limit, within_of(path)) +} + +/// [`offset_with_check`] of a finite `path` with the `within` test of its own segments. +fn offset_region( path: &PathData, delta: f64, join: Join, miter_limit: f64, within: impl Fn(kurbo::Point, f64) -> bool, ) -> Result { - if !delta.is_finite() || !path_is_finite(path) { - return Err(PathOpsError::NonFinite); - } if path.is_empty() { return Ok(PathData::default()); } @@ -630,14 +658,17 @@ mod tests { fn offsets_with_the_tree_are_identical_to_offsets_with_the_scan() { let (mut checks, mut hits, mut misses) = (0, 0, 0); for path in offset_inputs() { - let segs = boxed_segments(&path.to_bezpath()); for delta in [0.0, 0.05, 1.0, 5.0, 60.0, -0.05, -1.0, -5.0, -60.0] { for join in [Join::Round, Join::Miter, Join::Bevel] { let found = std::cell::Cell::new((0, 0)); - let by_scan = offset_with_check(&path, delta, join, 4.0, |p, r| { - let v = any_within_by_scan(&segs, p, r); - found.set((found.get().0 + usize::from(v), found.get().1 + usize::from(!v))); - v + let by_scan = offset_with_check(&path, delta, join, 4.0, |part| { + let segs = boxed_segments(&part.to_bezpath()); + let found = &found; + move |p, r| { + let v = any_within_by_scan(&segs, p, r); + found.set((found.get().0 + usize::from(v), found.get().1 + usize::from(!v))); + v + } }); checks += 1; hits += found.get().0; diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index 81e7f83ce7..492aaf56f1 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -316,6 +316,24 @@ fn offset_of_an_open_path_whose_ends_meet_is_the_closed_shape_offset() { assert_eq!(o.subpaths.len(), 1); } +#[test] +fn a_negative_offset_outlines_an_open_path_as_a_positive_one_does() { + // Issue #6427 (A4): an inset of an open path was empty. Open subpaths are outlined with a + // stroke of width 2|delta| whichever way the offset goes. + let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false)); + for delta in [2.0, -2.0] { + let o = offset_path(&line, delta, Join::Miter, 4.0); + assert!(close(ar(&o), 400.0, 1e-6), "delta {delta}: {}", ar(&o)); + assert_eq!(o.subpaths.len(), 1, "delta {delta}"); + } + // With a closed shape beside it, the shape is inset and the line still outlined. + let mut both = rect(0.0, 0.0, 20.0, 10.0); + both.subpaths.push(SubPath::polyline(&[Point::new(100.0, 0.0), Point::new(150.0, 0.0)], false)); + let o = offset_path(&both, -2.0, Join::Miter, 4.0); + assert!(close(ar(&o), 16.0 * 6.0 + 50.0 * 4.0, 1e-6), "{}", ar(&o)); + assert_eq!(o.subpaths.len(), 2); +} + #[test] fn outline_stroke_of_line() { let l = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(30.0, 40.0)], false));