collective

Browse / github.com/storytold/vectorcraft / patch

Fix #3773: Offset Path offsets a path whose ends meet as a closed shape

Offset Path produces an incorrect result for a supplied closed path A community report says Object > Path > Offset Path turns a single closed path into the incorrect geometry shown in the attached before/after screenshot. The reporter also supplied the native `.vectorcraft` document in Discord. Reported 2026-10-23 in the VectorCraft community channel. Expected: Offset Path preserves a sensible parallel offset for this closed path. Actual: the result has visibly incorrect geometry. Needs reproduction from the supplied document, then a regression fixture/test if confirmed.

Status
open
Score
0.00
Adopters
0 other operators
Author
dotCooCoo (imported from pull request #4362, open)
Base
8622d0c71c18a166d666314058be7f0e3e9b290f
Size
+55 −3 in 3 files
Review
pending
Shared
2026-10-28
Id
6d6445a55d76308c96af6098

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/tests/pathops.rs crates/pathops/src/offset.rs crates/pathops/tests/it/ops.rs

diff --git a/crates/engine/src/tests/pathops.rs b/crates/engine/src/tests/pathops.rs--- a/crates/engine/src/tests/pathops.rs+++ b/crates/engine/src/tests/pathops.rs@@ -430,6 +430,17 @@ fn offset_rect_grows_bounds() {     assert!(close(bounds(&s, r[0]), Rect::new(105.0, 105.0, 145.0, 135.0), 1e-2)); } +/// #3773: an open path whose last anchor sits on its first is offset as the closed shape, not as a+/// ring around its outline.+#[test]+fn offset_open_path_whose_ends_meet_grows_the_shape() {+    let mut s = session();+    s.execute("path.create", &json!({"d": "M0 0 L40 0 L40 40 L0 40 L0 0"})).unwrap();+    let r = ids(&s.execute("object.path.offsetPath", &json!({"offset": 2, "joins": "miter"})).unwrap());+    assert!((area_of(&s, r[0]) - 44.0 * 44.0).abs() < 1e-3, "{}", area_of(&s, r[0]));+    assert_eq!(path_of(&s, r[0]).0.subpaths.len(), 1);+}+ #[test] fn outline_stroke_of_line() {     let mut s = session();diff --git a/crates/pathops/src/offset.rs b/crates/pathops/src/offset.rs--- a/crates/pathops/src/offset.rs+++ b/crates/pathops/src/offset.rs@@ -94,13 +94,15 @@ pub fn stroke_region(outlines: &[BezPath]) -> PathData {  /// Object → Path → Offset Path. Positive `delta` grows the filled area, negative insets it. /// Closed subpaths are treated as a non-zero filled region; open subpaths are outlined with a-/// stroke of width `2|delta|` (butt caps).+/// stroke of width `2|delta|` (butt caps). An open subpath whose last anchor sits on its first is+/// offset as a closed one. pub fn offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) -> PathData {     if !delta.is_finite() || path.is_empty() {         return PathData::default();     }-    let closed = PathData::new(path.subpaths.iter().filter(|s| s.closed && s.anchors.len() > 1).cloned().collect());-    let open: Vec<SubPath> = path.subpaths.iter().filter(|s| !s.closed && s.anchors.len() > 1).cloned().collect();+    let subpaths: Vec<SubPath> = path.subpaths.iter().map(close_if_ends_meet).collect();+    let closed = PathData::new(subpaths.iter().filter(|s| s.closed && s.anchors.len() > 1).cloned().collect());+    let open: Vec<SubPath> = subpaths.into_iter().filter(|s| !s.closed && s.anchors.len() > 1).collect();     let d = delta.abs();     let fill = fill_bezpath(&closed);     let ring = if d > 0.0 { stroke_bez(&closed.to_bezpath(), 2.0 * d, Cap::Butt, join, miter_limit) } else { BezPath::new() };@@ -128,6 +130,23 @@ pub fn offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) ->     contours_to_path(&c, keep, &Tidy::free(DEFAULT_PRECISION)) } +/// An open subpath of three or more anchors whose last anchor lies within 1e-6 of its first (the+/// test Object → Path → Join applies to one path) as a closed subpath: the last anchor is merged+/// into the first, which takes its in-handle. Any other subpath is returned unchanged.+fn close_if_ends_meet(sp: &SubPath) -> SubPath {+    let mut sp = sp.clone();+    if !sp.closed+        && sp.anchors.len() > 2+        && sp.anchors.first().zip(sp.anchors.last()).is_some_and(|(f, l)| f.p.distance(l.p) < 1e-6)+        && let Some(last) = sp.anchors.pop()+        && let Some(first) = sp.anchors.first_mut()+    {+        first.h_in = last.h_in;+        sp.closed = true;+    }+    sp+}+ fn has_marked_ancestor(c: &Contours, mut i: usize, marked: &[bool]) -> bool {     loop {         if marked[i] {diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs--- a/crates/pathops/tests/it/ops.rs+++ b/crates/pathops/tests/it/ops.rs@@ -294,6 +294,28 @@ fn offset_rect_joins() {     assert!(offset_path(&r, -6.0, Join::Miter, 4.0).is_empty()); } +#[test]+fn offset_of_an_open_path_whose_ends_meet_is_the_closed_shape_offset() {+    // Issue #3773: an open path whose last anchor sits on its first offsets like the closed shape,+    // not as a ring around its outline.+    let corners = [Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(20.0, 10.0), Point::new(0.0, 10.0), Point::new(0.0, 0.0)];+    let r = PathData::single(SubPath::polyline(&corners, false));+    let grown = offset_path(&r, 2.0, Join::Miter, 4.0);+    assert!(close(ar(&grown), 24.0 * 14.0, 1e-6), "{}", ar(&grown));+    assert_eq!(grown.subpaths.len(), 1);+    assert_eq!(grown.anchor_count(), 4);+    assert!(close(ar(&offset_path(&r, -2.0, Join::Miter, 4.0)), 16.0 * 6.0, 1e-6));+    // A curved outline: the last anchor's in-handle shapes the closing curve.+    let mut sp = circle(0.0, 0.0, 10.0).subpaths.remove(0);+    let first = sp.anchors[0];+    sp.anchors.push(vectorcraft_geom::Anchor::with_handles(first.p, first.h_in, first.p));+    sp.anchors[0].h_in = first.p;+    sp.closed = false;+    let o = offset_path(&PathData::single(sp), 3.0, Join::Round, 4.0);+    assert!(close(ar(&o), PI * 169.0, 2e-3), "{}", ar(&o));+    assert_eq!(o.subpaths.len(), 1);+}+ #[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));