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
@@ -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)?;@@ -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| {