collective

Browse / github.com/storytold/cadcraft / patch

Drop collapsed segments when offsetting a polyline

ARC offset <img width="839" height="559" alt="Image" src="https://github.com/user-attachments/assets/4a9720c5-d3f7-4885-a7d2-3200986c786b" /> ## Summary OFFSET on a closed polyline that contains an arc (bulge) segment gives a wrong result on the arc. Straight segments offset correctly. The first one or two offsets of a shape look fine, later ones do not. ## Environment - CADCraft (Windows x64 desktop build, installed via MSI), early alpha - Drawing: new empty Drawing1, Model space, 2D Wireframe, Top view - Units / settings: OFFSET default settings (Erase source=No, Layer=Source, OFFSETGAPTYPE=0) ## St

Status
open
Score
0.00
Adopters
0 other operators
Author
Silvertree2010 (imported from pull request #2151, open)
Base
754b609d09e884bcec545ee84aec7e8e702aa53e
Size
+83 −15 in 2 files
Review
pending
Shared
2026-10-28
Id
bc17a3f625d4c653069293db

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/modify.rs crates/engine/src/cmd/modify2_tests.rs

diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs--- a/crates/engine/src/cmd/modify.rs+++ b/crates/engine/src/cmd/modify.rs@@ -727,22 +727,55 @@ fn offset_points(pts: &[Vec2], d: f64, closed: bool) -> Vec<Vec2> {  /// Offset a polyline: offset each segment, then join neighbours at their intersections. pub(crate) fn offset_polyline(pl: &Polyline, d: f64) -> Option<Vec<PolyVertex>> {-    let segs: Vec<Segment> = pl.segments().iter().filter_map(|s| s.offset(d)).collect();-    if segs.is_empty() {-        return None;-    }-    let n = segs.len();-    // Where each offset segment starts after joining it to the previous one.-    let mut starts: Vec<Vec2> = Vec::with_capacity(n);-    for i in 0..n {-        let s = segs.get(i)?;-        starts.push(if i == 0 && !pl.closed {-            s.start()-        } else {-            let prev = segs.get((i + n - 1) % n)?;-            join_point(prev, s).unwrap_or(s.start())+    let mut segs: Vec<Segment> = pl.segments().iter().filter_map(|s| s.offset(d)).collect();+    // Each pass drops at most one collapsed segment, so this bounds the loop.+    let mut removals = segs.len();+    let starts = loop {+        let n = segs.len();+        if n == 0 || (pl.closed && n < 2) {+            return None;+        }+        // Where each offset segment starts after joining it to the previous one (`None`: they don't meet).+        let mut joins: Vec<Option<Vec2>> = Vec::with_capacity(n);+        for i in 0..n {+            let s = segs.get(i)?;+            joins.push(if i == 0 && !pl.closed { Some(s.start()) } else { join_point(segs.get((i + n - 1) % n)?, s) });+        }+        let start_of = |i: usize| joins.get(i).copied().flatten().or_else(|| segs.get(i).map(Segment::start));+        // A segment that collapsed: a line whose joined ends turned around, or an arc whose circle no+        // longer meets a neighbour or shrank to a point between its joins. Drop it and join its neighbours.+        let collapsed = (0..n).position(|i| {+            let Some(s) = segs.get(i) else { return false };+            let (start_join, end_join) = if i + 1 < n {+                (joins.get(i).copied().flatten(), joins.get(i + 1).copied().flatten())+            } else if pl.closed {+                (joins.get(i).copied().flatten(), joins.first().copied().flatten())+            } else {+                (joins.get(i).copied().flatten(), Some(s.end()))+            };+            let start = start_of(i).unwrap_or(s.start());+            let end = if i + 1 < n {+                start_of(i + 1)+            } else if pl.closed {+                start_of(0)+            } else {+                Some(s.end())+            }+            .unwrap_or(s.end());+            match s {+                Segment::Line(l) => (end - start).dot(l.dir()) <= 1e-9,+                Segment::Arc { .. } => start_join.is_none() || end_join.is_none() || start.near(end, 1e-9),+            }         });-    }+        match collapsed {+            Some(i) if removals > 0 => {+                segs.remove(i);+                removals -= 1;+            }+            _ => break (0..n).map(start_of).collect::<Option<Vec<Vec2>>>()?,+        }+    };+    let n = segs.len();     let mut verts: Vec<PolyVertex> = Vec::new();     for i in 0..n {         let s = segs.get(i)?;diff --git a/crates/engine/src/cmd/modify2_tests.rs b/crates/engine/src/cmd/modify2_tests.rs--- a/crates/engine/src/cmd/modify2_tests.rs+++ b/crates/engine/src/cmd/modify2_tests.rs@@ -755,6 +755,41 @@ fn offset_polyline_arc_bulge_follows_joined_endpoints() {     assert!((arc.radius - 4.0).abs() < 1e-6, "radius {}", arc.radius); } +#[test]+fn offset_closed_niche_polyline_repeatedly_stays_valid() {+    // A square with a small semicircular niche (centre (5, 10), radius 2) on its top edge, offset+    // inward 0.5 five times, each time from the previous result.+    let mut s = Session::new();+    let mut src = pline(&mut s, json!([[0, 0], [10, 0], [10, 10], {"p": [7, 10], "bulge": 1}, [3, 10], [0, 10]]), true);+    for n in 1..=5 {+        let r = s.execute("offset", &json!({"handle": src, "distance": 0.5, "side": [5, 5]})).unwrap();+        src = h(&r);+        let p = poly(&kind(&s, &src));+        assert!(p.closed);+        let (lo, hi) = (0.5 * n as f64, 10.0 - 0.5 * n as f64);+        let count = p.vertices.len();+        let mut arcs = 0;+        for (i, v) in p.vertices.iter().enumerate() {+            let next = p.vertices[(i + 1) % count];+            assert!(v.p.x > lo - 1e-6 && v.p.x < hi + 1e-6 && v.p.y > lo - 1e-6, "offset {n}: vertex {:?} outside", v.p);+            assert!(!v.p.near(next.p, 1e-9), "offset {n}: zero-length segment at {:?}: {:?}", v.p, p.vertices);+            if v.bulge != 0.0 {+                arcs += 1;+                let (arc, _) = cadcraft_geom::bulge_to_arc(v.p, next.p, v.bulge).unwrap();+                assert!(near(arc.center, 5.0, 10.0), "offset {n}: arc centre {:?}", arc.center);+                assert!((arc.radius - (2.0 - 0.5 * n as f64)).abs() < 1e-6, "offset {n}: radius {}", arc.radius);+            } else {+                let d = next.p - v.p;+                assert!(d.x.abs() < 1e-6 || d.y.abs() < 1e-6, "offset {n}: diagonal {:?} -> {:?}: {:?}", v.p, next.p, p.vertices);+                assert!(v.p.y < hi + 1e-6 && next.p.y < hi + 1e-6, "offset {n}: line above the top edge");+            }+        }+        if n == 1 {+            assert_eq!(arcs, 1, "offset 1 keeps the niche arc: {:?}", p.vertices);+        }+    }+}+ #[test] fn offset_elliptical_arc_follows_picked_side() {     let max_x = |side: Value| {