From 44f0c98c400fc9c9e22674fac35a419a2dfbf29f Mon Sep 17 00:00:00 2001 From: jayzed80 Date: Thu, 12 Nov 2026 19:49:10 +0000 Subject: [PATCH] Offset Path: an inset keeps the outline of open paths (#6427) A negative offset kept only the faces inside the closed fill, so the stroke of every open subpath was dropped and an open line inset to nothing. Inset the closed subpaths, outline the open ones with a 2|delta| butt stroke as a positive offset does, and unite the two. --- crates/pathops/src/offset.rs | 55 ++++++++++++++++++++++++++-------- crates/pathops/tests/it/ops.rs | 18 +++++++++++ 2 files changed, 61 insertions(+), 12 deletions(-) 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));