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| {
