diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs
--- a/crates/doc/src/entity.rs
+++ b/crates/doc/src/entity.rs
@@ -44,14 +44,16 @@ mod dim;
 mod fill;
 mod layout;
 mod text;
+mod wipeout;
 
 pub use block::{Attrib, AttribModes, AttribProps, Insert, MultilineAttrib, mtext_literal};
 pub use common::{Common, Handle, Lineweight, RawTag, Transparency};
 pub use curves::{Arc, Circle, Ellipse, Face3d, Line, LwPolyline, Point, Polyline3d, RayLine, Solid};
 pub use dim::{AssocSnap, DimAssoc, DimBreak, DimKind, DimMarks, Dimension, Inspection, JoggedRadius, Leader, MLeader};
-pub use fill::{Gradient, Hatch, HatchLoop, Wipeout};
+pub use fill::{Gradient, Hatch, HatchLoop};
 pub use layout::{Image, Table, TableCell, Viewport};
 pub use text::{HAlign, MText, MTextColumnType, MTextColumns, Text, VAlign};
+pub use wipeout::Wipeout;
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
 pub struct Unknown {
     pub dxf_type: String,
@@ -226,11 +228,7 @@ impl EntityKind {
                 h.loops.iter().flat_map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.segments()).map(Prim::Seg).collect()
             }
             EntityKind::Wipeout(w) => {
-                let mut pts = w.boundary.clone();
-                if let Some(f) = pts.first().copied() {
-                    pts.push(f);
-                }
-                pts.windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(*w.first()?, *w.get(1)?))))).collect()
+                w.closed_boundary().windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(*w.first()?, *w.get(1)?))))).collect()
             }
             EntityKind::Viewport(v) => {
                 let c = v.center.xy();
@@ -535,7 +533,7 @@ impl EntityKind {
                 Bounds2::new(c - h, c + h).corners().to_vec()
             }
             EntityKind::Image(i) => vec![i.insert.xy()],
-            EntityKind::Wipeout(w) => w.boundary.clone(),
+            EntityKind::Wipeout(w) => w.vertices().to_vec(),
             EntityKind::Table(t) => vec![t.insert.xy()],
             EntityKind::Tolerance(t) => vec![t.insert.xy()],
             EntityKind::Unknown(_) => Vec::new(),
diff --git a/crates/doc/src/entity/fill.rs b/crates/doc/src/entity/fill.rs
--- a/crates/doc/src/entity/fill.rs
+++ b/crates/doc/src/entity/fill.rs
@@ -54,8 +54,3 @@ pub struct Gradient {
     #[serde(default)]
     pub centered: bool,
 }
-
-#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
-pub struct Wipeout {
-    pub boundary: Vec<Vec2>,
-}
diff --git a/crates/doc/src/entity/wipeout.rs b/crates/doc/src/entity/wipeout.rs
new file mode 100644
--- /dev/null
+++ b/crates/doc/src/entity/wipeout.rs
@@ -0,0 +1,162 @@
+//! WIPEOUT is always a closed masking polygon, unlike a polyline with an open/closed flag.
+//! One exact terminal copy of the first point is a closure marker, not an editable corner.
+//! Preserve logical corners when editing, adding a separate marker if the endpoints meet.
+//! Grips, edits and explicit outlines share this rule; other duplicates are not simplified.
+
+use std::borrow::Cow;
+
+use cadcraft_geom::Vec2;
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct Wipeout {
+    /// Ordered boundary, implicitly closed or ending in a repeated first point.
+    pub boundary: Vec<Vec2>,
+}
+
+impl Wipeout {
+    /// Editable vertices, excluding one exactly repeated closing point, without changing storage.
+    pub fn vertices(&self) -> &[Vec2] {
+        match self.boundary.split_last() {
+            Some((last, vertices)) if vertices.first() == Some(last) => vertices,
+            _ => &self.boundary,
+        }
+    }
+
+    /// An explicitly closed outline for geometry, rendering and export. Already closed or
+    /// empty/single-point boundaries are borrowed; otherwise one closing point is appended.
+    pub fn closed_boundary(&self) -> Cow<'_, [Vec2]> {
+        if self.vertices().len() < self.boundary.len() || self.boundary.len() < 2 {
+            Cow::Borrowed(&self.boundary)
+        } else {
+            let mut points = self.boundary.clone();
+            points.extend(self.boundary.first());
+            Cow::Owned(points)
+        }
+    }
+
+    /// Move an editable vertex, carrying an existing closing marker with vertex zero. If an
+    /// implicit boundary's endpoints meet, add a marker so both corners remain addressable.
+    /// Invalid indices/non-finite points leave the boundary unchanged.
+    pub fn set_vertex(&mut self, index: usize, to: Vec2) -> Option<()> {
+        let count = self.vertices().len();
+        if index >= count || !to.is_finite() {
+            return None;
+        }
+        let explicit = count < self.boundary.len();
+        *self.boundary.get_mut(index)? = to;
+        if explicit
+            && index == 0
+            && let Some(last) = self.boundary.last_mut()
+        {
+            *last = to;
+        }
+        if !explicit && self.vertices().len() < count {
+            // The moved endpoint is a logical corner, not the newly implied closing marker.
+            if let Some(first) = self.boundary.first().copied() {
+                self.boundary.push(first);
+            }
+        }
+        Some(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::EntityKind;
+
+    #[test]
+    fn vertices_and_grips_exclude_only_the_exact_closing_point() {
+        let (a, b, c) = (Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0));
+        let near_a = Vec2::new(1e-13, 0.0);
+        for (boundary, expected) in [
+            (vec![], vec![]),
+            (vec![a], vec![a]),
+            (vec![a, a], vec![a]),
+            (vec![a, b, c], vec![a, b, c]),
+            (vec![a, b, c, a], vec![a, b, c]),
+            (vec![a, b, a, c, a], vec![a, b, a, c]),
+            (vec![a, b, c, a, a], vec![a, b, c, a]),
+            (vec![a, b, c, near_a], vec![a, b, c, near_a]),
+        ] {
+            let wipeout = Wipeout { boundary: boundary.clone() };
+            assert_eq!(wipeout.vertices(), expected);
+            assert_eq!(EntityKind::Wipeout(wipeout.clone()).grips(), expected);
+            assert_eq!(wipeout.boundary, boundary);
+        }
+    }
+
+    #[test]
+    fn explicit_outline_is_stable_and_does_not_add_zero_length_closing_edges() {
+        let points = vec![Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0)];
+        let implicit = Wipeout { boundary: points.clone() };
+        let mut expected = points.clone();
+        expected.push(points[0]);
+        assert!(matches!(implicit.closed_boundary(), Cow::Owned(_)));
+        assert_eq!(implicit.closed_boundary().as_ref(), expected);
+        assert_eq!(implicit.boundary, points);
+        let explicit = Wipeout { boundary: expected.clone() };
+        assert!(matches!(explicit.closed_boundary(), Cow::Borrowed(_)));
+        assert_eq!(explicit.closed_boundary().as_ref(), expected);
+        let a = EntityKind::Wipeout(implicit).prims();
+        let b = EntityKind::Wipeout(explicit).prims();
+        assert_eq!(a, b);
+        assert_eq!(a.len(), 3);
+        for boundary in [vec![], vec![Vec2::ZERO]] {
+            let wipeout = Wipeout { boundary: boundary.clone() };
+            assert!(matches!(wipeout.closed_boundary(), Cow::Borrowed(_)));
+            assert_eq!(wipeout.closed_boundary().as_ref(), boundary);
+            assert!(EntityKind::Wipeout(wipeout).prims().is_empty());
+        }
+    }
+
+    #[test]
+    fn moving_endpoints_together_keeps_both_corners_editable() {
+        let points = [Vec2::ZERO, Vec2::new(8.0, 0.0), Vec2::new(8.0, 6.0), Vec2::new(0.0, 6.0)];
+        for explicit in [false, true] {
+            for index in [0, 3] {
+                let other = 3 - index;
+                let mut boundary = points.to_vec();
+                if explicit {
+                    boundary.push(points[0]);
+                }
+                let mut wipeout = Wipeout { boundary };
+                let mut expected = points;
+                expected[index] = points[other];
+                for _ in 0..2 {
+                    assert_eq!(wipeout.set_vertex(index, points[other]), Some(()));
+                    assert_eq!(wipeout.vertices(), expected);
+                    assert_eq!(wipeout.boundary.len(), 5, "one separate closing marker, no growth");
+                }
+                expected[other] = Vec2::new(-2.0, 4.0);
+                assert_eq!(wipeout.set_vertex(other, expected[other]), Some(()));
+                assert_eq!(wipeout.vertices(), expected);
+                assert_eq!(wipeout.boundary.len(), 5);
+                assert_eq!(wipeout.boundary.first(), wipeout.boundary.last());
+            }
+        }
+    }
+
+    #[test]
+    fn editing_keeps_the_closure_but_not_other_coincident_vertices_attached() {
+        let (a, b, c, to) = (Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0), Vec2::new(2.0, 3.0));
+        for explicit in [false, true] {
+            let mut boundary = vec![a, b, a, c];
+            if explicit {
+                boundary.push(a);
+            }
+            let mut wipeout = Wipeout { boundary };
+            assert_eq!(wipeout.set_vertex(0, to), Some(()));
+            let mut expected = vec![to, b, a, c];
+            if explicit {
+                expected.push(to);
+            }
+            assert_eq!(wipeout.boundary, expected);
+            for (index, point) in [(4, a), (usize::MAX, a), (0, Vec2::new(f64::NAN, 0.0)), (0, Vec2::new(0.0, f64::INFINITY))] {
+                assert_eq!(wipeout.set_vertex(index, point), None);
+                assert_eq!(wipeout.boundary, expected);
+            }
+        }
+    }
+}
diff --git a/crates/engine/src/grips.rs b/crates/engine/src/grips.rs
--- a/crates/engine/src/grips.rs
+++ b/crates/engine/src/grips.rs
@@ -294,7 +294,7 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option<EntityK
             v.width = w;
             v.height = h;
         }
-        EntityKind::Wipeout(w) => *w.boundary.get_mut(index)? = to,
+        EntityKind::Wipeout(w) => w.set_vertex(index, to)?,
         EntityKind::Unknown(_) => return None,
     }
     Some(k)
diff --git a/crates/engine/src/grips/tests.rs b/crates/engine/src/grips/tests.rs
--- a/crates/engine/src/grips/tests.rs
+++ b/crates/engine/src/grips/tests.rs
@@ -157,6 +157,21 @@ fn other_entity_grips() {
     assert!(matches!(kind(&s, pt), EntityKind::Point(p) if near(p.p.xy(), 2.0, 2.0)));
 }
 
+#[test]
+fn wipeout_grips_reject_the_closing_marker_and_hostile_input() {
+    let kind = EntityKind::Wipeout(cadcraft_doc::Wipeout { boundary: vec![v(0.0, 0.0), v(7.0, 1.0), v(3.0, 7.0), v(0.0, 0.0)] });
+    for (index, to) in [(3, v(2.0, 2.0)), (usize::MAX, v(2.0, 2.0)), (0, v(f64::NAN, 0.0)), (0, v(0.0, f64::INFINITY))] {
+        assert!(stretch_grip(&kind, index, to).is_none());
+    }
+    for boundary in [vec![], vec![v(0.0, 0.0)], vec![v(0.0, 0.0), v(0.0, 0.0)]] {
+        let kind = EntityKind::Wipeout(cadcraft_doc::Wipeout { boundary });
+        for index in [0, 1, usize::MAX] {
+            let result = stretch_grip(&kind, index, v(2.0, 2.0));
+            assert_eq!(result.is_some(), index < kind.grips().len());
+        }
+    }
+}
+
 #[test]
 fn grip_modes() {
     let mut s = Session::new();
diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs
--- a/crates/engine/tests/it/main.rs
+++ b/crates/engine/tests/it/main.rs
@@ -140,6 +140,7 @@ mod undo_layout_tab;
 mod viewport_clip;
 mod vplayer_prompts;
 mod vports;
+mod wipeout_grips;
 mod xdata_follows_entity;
 mod zoom_keywords;
 mod zoom_viewport;
diff --git a/crates/engine/tests/it/wipeout_grips.rs b/crates/engine/tests/it/wipeout_grips.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/tests/it/wipeout_grips.rs
@@ -0,0 +1,227 @@
+//! WIPEOUT's optional repeated closing point is not a second editable corner (#2256).
+//! Authored contours keep their grips and edit results through DXF/DWG saves, both via the
+//! command API and the interactive grip machine, without changing the file's explicit closure.
+
+use cadcraft_doc::{Drawing, EntityKind, Handle, LwPolyline, Space};
+use cadcraft_engine::{
+    Input, Session,
+    grips::{GripMode, stretch_grip},
+};
+use cadcraft_geom::{PolyVertex, Vec2};
+use cadcraft_io::{SaveOptions, Version};
+use serde_json::json;
+
+fn fixture(explicit: bool, reversed: bool) -> (Drawing, Handle, Vec<Vec2>) {
+    let mut points = vec![Vec2::new(1.0, 2.0), Vec2::new(7.0, 3.0), Vec2::new(4.0, 9.0)];
+    if reversed {
+        points.reverse();
+    }
+    let mut input = points.clone();
+    if explicit {
+        input.push(points[0]);
+    }
+    let mut session = Session::new();
+    let input: Vec<_> = input.iter().map(|p| [p.x, p.y]).collect();
+    let reply = session.execute("wipeout", &json!({"points": input})).unwrap();
+    let handle = Handle::parse_hex(reply["handle"].as_str().unwrap()).unwrap();
+    (session.doc().unwrap().clone(), handle, points)
+}
+
+fn points_equal(actual: &[Vec2], expected: &[Vec2]) {
+    assert_eq!(actual.len(), expected.len(), "{actual:?} != {expected:?}");
+    for (a, b) in actual.iter().zip(expected) {
+        assert!(a.near(*b, 1e-12), "{actual:?} != {expected:?}");
+    }
+}
+
+fn assert_boundary(drawing: &Drawing, handle: Handle, points: &[Vec2], explicit: bool) {
+    let EntityKind::Wipeout(w) = &drawing.entity(handle).unwrap().kind else { panic!("WIPEOUT lost") };
+    let mut expected = points.to_vec();
+    if explicit {
+        expected.push(points[0]);
+    }
+    points_equal(&w.boundary, &expected);
+}
+
+fn move_grip(session: &mut Session, handle: Handle, index: usize, to: Vec2, interactive: bool) {
+    session.set_selection(vec![handle]);
+    if interactive {
+        session.start_grip_edit(&[(handle, index)]).unwrap();
+        session.input(Input::Point(to)).unwrap();
+        assert!(!session.prompting());
+    } else {
+        session.grip_edit(handle, index, to, GripMode::Stretch).unwrap();
+    }
+}
+
+fn edit(drawing: Drawing, handle: Handle, points: &[Vec2], explicit: bool, index: usize, interactive: bool) -> Drawing {
+    let mut session = Session::new();
+    session.open_drawing(drawing, "wipeout", None);
+    points_equal(&session.grips_of(handle).unwrap(), points);
+    let to = points[index] + Vec2::new(2.0, 1.0);
+    let mut expected = points.to_vec();
+    expected[index] = to;
+    move_grip(&mut session, handle, index, to, interactive);
+    assert_boundary(session.doc().unwrap(), handle, &expected, explicit);
+    points_equal(&session.grips_of(handle).unwrap(), &expected);
+    session.execute("undo", &json!({})).unwrap();
+    assert_boundary(session.doc().unwrap(), handle, points, explicit);
+    session.execute("redo", &json!({})).unwrap();
+    assert_boundary(session.doc().unwrap(), handle, &expected, explicit);
+    session.doc().unwrap().clone()
+}
+
+fn check(format: &str, interactive: bool) {
+    for explicit in [false, true] {
+        for reversed in [false, true] {
+            let (drawing, handle, points) = fixture(explicit, reversed);
+            for index in 0..points.len() {
+                edit(drawing.clone(), handle, &points, explicit, index, interactive);
+            }
+            for version in Version::ALL {
+                for binary in [false, true].into_iter().filter(|binary| !*binary || format == "wipeout.dxf") {
+                    let options = SaveOptions { version: Some(version), binary };
+                    let mut current = drawing.clone();
+                    for _pass in 0..2 {
+                        let bytes = cadcraft_io::write_with(&current, format, &options).unwrap();
+                        current = cadcraft_io::read(&bytes, format).unwrap();
+                        assert!(current.read_notes.is_empty() && current.skipped_records.is_empty());
+                        assert_boundary(&current, handle, &points, true);
+                        for index in 0..points.len() {
+                            let edited = edit(current.clone(), handle, &points, true, index, interactive);
+                            let mut expected = points.clone();
+                            expected[index] += Vec2::new(2.0, 1.0);
+                            let bytes = cadcraft_io::write_with(&edited, format, &options).unwrap();
+                            let back = cadcraft_io::read(&bytes, format).unwrap();
+                            assert_boundary(&back, handle, &expected, true);
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+#[test]
+fn dxf_api_grips_survive_roundtrip() {
+    check("wipeout.dxf", false);
+}
+
+#[test]
+fn dxf_interactive_grips_survive_roundtrip() {
+    check("wipeout.dxf", true);
+}
+
+#[test]
+fn dwg_api_grips_survive_roundtrip() {
+    check("wipeout.dwg", false);
+}
+
+#[test]
+fn dwg_interactive_grips_survive_roundtrip() {
+    check("wipeout.dwg", true);
+}
+
+#[test]
+fn polyline_closedness_is_not_inferred_from_coincident_endpoints() {
+    for closed in [false, true] {
+        for repeated in [false, true] {
+            let mut points = vec![Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0)];
+            if repeated {
+                points.push(points[0]);
+            }
+            let polyline = LwPolyline {
+                vertices: points.iter().copied().map(PolyVertex::new).collect(),
+                closed,
+                const_width: 0.0,
+                elevation: 0.0,
+                plinegen: false,
+            };
+            let kind = EntityKind::LwPolyline(polyline);
+            let segments = if repeated || closed { 3 } else { 2 };
+            assert_eq!(kind.prims().len(), segments);
+            assert_eq!(kind.grips().len(), points.len() + segments);
+            let to = Vec2::new(2.0, 3.0);
+            let EntityKind::LwPolyline(edited) = stretch_grip(&kind, 0, to).unwrap() else { panic!("polyline") };
+            assert_eq!(edited.closed, closed);
+            let mut expected = points.clone();
+            expected[0] = to;
+            assert_eq!(edited.vertices.iter().map(|v| v.p).collect::<Vec<_>>(), expected);
+
+            // WIPEOUT conversion requires the closed flag, even with coincident endpoints.
+            let mut session = Session::new();
+            let handle = session.doc_mut().unwrap().add(&Space::Model, Default::default(), kind).unwrap();
+            let result = session.execute("wipeout", &json!({"handle": handle.hex()}));
+            assert_eq!(result.is_ok(), closed);
+            if let Ok(result) = result {
+                let wipeout = Handle::parse_hex(result["handle"].as_str().unwrap()).unwrap();
+                assert_boundary(session.doc().unwrap(), wipeout, &points[..3], repeated);
+                points_equal(&session.grips_of(wipeout).unwrap(), &points[..3]);
+            } else {
+                assert_eq!(session.doc().unwrap().model.len(), 1);
+            }
+        }
+    }
+}
+
+#[test]
+fn coincident_endpoints_remain_separately_editable_after_save() {
+    let original = [Vec2::ZERO, Vec2::new(8.0, 0.0), Vec2::new(8.0, 6.0), Vec2::new(0.0, 6.0)];
+    for explicit in [false, true] {
+        for interactive in [false, true] {
+            for index in [0, 3] {
+                let other = 3 - index;
+                let mut input: Vec<_> = original.iter().map(|p| [p.x, p.y]).collect();
+                if explicit {
+                    input.push(input[0]);
+                }
+                let mut session = Session::new();
+                let reply = session.execute("wipeout", &json!({"points": input})).unwrap();
+                let handle = Handle::parse_hex(reply["handle"].as_str().unwrap()).unwrap();
+                let mut coincident = original;
+                coincident[index] = original[other];
+                move_grip(&mut session, handle, index, coincident[index], interactive);
+                points_equal(&session.grips_of(handle).unwrap(), &coincident);
+                assert_boundary(session.doc().unwrap(), handle, &coincident, true);
+                session.undo().unwrap();
+                assert_boundary(session.doc().unwrap(), handle, &original, explicit);
+                session.execute("redo", &json!({})).unwrap();
+                points_equal(&session.grips_of(handle).unwrap(), &coincident);
+                let collapsed = session.doc().unwrap().clone();
+                for version in Version::ALL {
+                    for (format, binary) in [("wipeout.dxf", false), ("wipeout.dxf", true), ("wipeout.dwg", false)] {
+                        let options = SaveOptions { version: Some(version), binary };
+                        let bytes = cadcraft_io::write_with(&collapsed, format, &options).unwrap();
+                        let back = cadcraft_io::read(&bytes, format).unwrap();
+                        session.open_drawing(back, "coincident corners", None);
+                        points_equal(&session.grips_of(handle).unwrap(), &coincident);
+                        assert_boundary(session.doc().unwrap(), handle, &coincident, true);
+                        let mut separated = coincident;
+                        separated[other] = Vec2::new(-2.0, 4.0);
+                        move_grip(&mut session, handle, other, separated[other], interactive);
+                        points_equal(&session.grips_of(handle).unwrap(), &separated);
+                        assert_boundary(session.doc().unwrap(), handle, &separated, true);
+                        let bytes = cadcraft_io::write_with(session.doc().unwrap(), format, &options).unwrap();
+                        let back = cadcraft_io::read(&bytes, format).unwrap();
+                        assert_boundary(&back, handle, &separated, true);
+                    }
+                }
+            }
+        }
+    }
+}
+
+#[test]
+fn multiple_hot_grips_keep_the_closing_point_attached() {
+    let (drawing, handle, mut points) = fixture(true, false);
+    let mut session = Session::new();
+    session.open_drawing(drawing, "wipeout", None);
+    session.set_selection(vec![handle]);
+    session.start_grip_edit(&[(handle, 0), (handle, 1)]).unwrap();
+    let delta = Vec2::new(2.0, 1.0);
+    session.input(Input::Point(points[0] + delta)).unwrap();
+    points[0] += delta;
+    points[1] += delta;
+    assert_boundary(session.doc().unwrap(), handle, &points, true);
+    points_equal(&session.grips_of(handle).unwrap(), &points);
+}
diff --git a/crates/io/src/dxf_write/entity.rs b/crates/io/src/dxf_write/entity.rs
--- a/crates/io/src/dxf_write/entity.rs
+++ b/crates/io/src/dxf_write/entity.rs
@@ -618,14 +618,9 @@ pub(super) fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: boo
             w.i(282, 50);
             w.i(283, 0);
             w.i(71, 2);
-            let mut pts = wo.boundary.clone();
-            if let (Some(first), Some(last)) = (pts.first().copied(), pts.last().copied())
-                && first != last
-            {
-                pts.push(first);
-            }
+            let pts = wo.closed_boundary();
             w.i(91, pts.len() as i64);
-            for q in pts {
+            for q in pts.iter() {
                 w.p2(14, Vec2::new((q.x - o.x) / sx - 0.5, 0.5 - (q.y - o.y) / sy));
             }
         }
diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs
--- a/crates/render/src/draw.rs
+++ b/crates/render/src/draw.rs
@@ -237,11 +237,7 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) {
                 _ => true,
             };
             if frame {
-                let mut pts = w.boundary.clone();
-                if let Some(f) = pts.first().copied() {
-                    pts.push(f);
-                }
-                b.polyline(ctx, rgb, lw, &pts);
+                b.polyline(ctx, rgb, lw, &w.closed_boundary());
             }
         }
         EntityKind::Image(i) => {
diff --git a/crates/render/tests/it/wipeout_mask.rs b/crates/render/tests/it/wipeout_mask.rs
--- a/crates/render/tests/it/wipeout_mask.rs
+++ b/crates/render/tests/it/wipeout_mask.rs
@@ -59,6 +59,43 @@ fn wipeout_hides_what_is_drawn_before_it() {
     assert!(inside > 0);
 }
 
+#[test]
+fn implicit_and_explicit_closure_draw_the_same_mask_and_frame() {
+    for reversed in [false, true] {
+        let mut implicit = drawing(false);
+        let handle = implicit.model.iter().find(|e| matches!(e.kind, EntityKind::Wipeout(_))).unwrap().handle;
+        if reversed {
+            implicit.model.modify(handle, |e| {
+                let EntityKind::Wipeout(w) = &mut e.kind else { panic!("wipeout") };
+                w.boundary.reverse();
+            });
+        }
+        let mut explicit = implicit.clone();
+        explicit.model.modify(handle, |e| {
+            let EntityKind::Wipeout(w) = &mut e.kind else { panic!("wipeout") };
+            w.boundary.push(w.boundary[0]);
+        });
+        for frame in [0, 1, 2] {
+            implicit.header.set_i64("WIPEOUTFRAME", frame);
+            explicit.header.set_i64("WIPEOUTFRAME", frame);
+            for plot in [false, true] {
+                let draw = if plot { build_plot } else { build };
+                let a = draw(&implicit, &Space::Model, &Options::default());
+                let b = draw(&explicit, &Space::Model, &Options::default());
+                assert_eq!(a.prims, b.prims);
+                assert_eq!(a.verts, b.verts);
+                assert_eq!(a.tris, b.tris);
+                for primitive in b.prims.iter().filter(|p| p.handle == handle && p.kind == Kind::Polyline) {
+                    let points = b.points(primitive);
+                    assert_eq!(points.len(), 5, "four corners and exactly one closing point");
+                    assert_eq!(points.first(), points.last());
+                }
+                assert_eq!(dark_pixels(&a), dark_pixels(&b));
+            }
+        }
+    }
+}
+
 #[test]
 fn wipeoutframe_controls_the_frame() {
     // Polylines in the list: the line, plus the frame when it is drawn.
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
 # File-format parity
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (DIMASSOC objects for angular, diametric and radial dimensions) · **Target:** Autodesk AutoCAD 2027
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (WIPEOUT closure and edit preservation) · **Target:** Autodesk AutoCAD 2027
 
 Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work
 items are in [gaps.md](gaps.md#file-formats); the weighted score feeds
@@ -87,7 +87,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG
 | ACAD_TABLE | yes | yes (entity + block) | |
 | VIEWPORT | yes | yes; frozen-layer lists kept (#150), also as standard group 341 layer handles that other programs read | DWG: the DXF→DWG converter does not yet write group 341 (frozen layers survive a CADCraft round trip via xdata); VP layer colour overrides lost (#99, PR #197) |
 | IMAGE | yes, with IMAGEDEF | yes, with IMAGEDEF and IMAGEDEF_REACTOR | no attach command or display of the picture yet (FF10) |
-| WIPEOUT | yes | yes (#147, fixes #79) | |
+| WIPEOUT | yes | yes (#147, fixes #79) | Implicit or explicit closure in memory; explicit closing point on save. Same logical grips and edit results before/after DXF/DWG round trips (#2256). |
 | MLINE (+ MLINESTYLE), HELIX, TOLERANCE | yes (#510, #420) | yes | |
 | REGION | other programs' REGIONs (ACIS data) kept as Unknown | our regions are written as closed polylines tagged with CADCraft xdata (#510; ACIS has no public spec) | |
 | 3DSOLID, BODY, SURFACE, MESH, SHAPE, UNDERLAY, OLE2FRAME, LIGHT, CAMERA, SECTION, POINTCLOUD, proxy entities | kept as Unknown (raw tags) | written back as read, with their CLASS records (#528); a proxy entity's class number (group 91) is renumbered to its class's place in the written CLASSES (#1812); **dropped by DWG save** (acadrust) | only their layer follows edits |
@@ -140,6 +140,7 @@ our output in AutoCAD black-box.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-27 | minor | WIPEOUT keeps its explicit closing point on save without accumulating duplicates; geometry, outline and grips share closure handling. Six-version DXF ASCII/binary and DWG edit/undo/redo regressions (#2256) |
 | 2026-10-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) |
 | 2026-10-19 | minor | DXF/DWG save: angular dimension block text honors DIMDSEP, including per-dimension overrides (#1646) |
 | 2026-10-19 | minor | A file's own arrowhead blocks with standard names (AutoCAD's `_Dot`, `_DotSmall`), named only by a dimension style, are kept and saved as they were instead of being replaced by CADCraft's solids; PURGE keeps arrowhead blocks dimension styles name |
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,6 +1,6 @@
 # UI parity
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (distinct polyline midpoint grips) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (WIPEOUT closure-aware grips) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)
 
 How CADCraft's interaction compares with AutoCAD's: point entry, object snaps, tracking, dynamic
 input, grips, selection, the command line, chrome and navigation. A drafter's speed in AutoCAD
@@ -111,7 +111,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM.
 
 | Feature | AutoCAD | CADCraft |
 |---|---|---|
-| Stretch grips per object type | yes | lines (midpoint moves), arcs (start/mid/end), ellipses (axes), polylines (vertices; segment midpoints move or reshape the bulge), dimensions (text and definition points), MLEADER, viewport, wipeout, spline (`grips.rs:79-270`) |
+| Stretch grips per object type | yes | lines (midpoint moves), arcs (start/mid/end), ellipses (axes), polylines (vertices; segment midpoints move or reshape the bulge), dimensions (text and definition points), MLEADER, viewport, wipeout (one grip per logical corner, including coincident corners; an explicit closing marker follows the first vertex), spline (`grips.rs`) |
 | Text/MText grips | position, width, column grips | move the whole object |
 | Hot-grip modes with Space/Enter cycling | Stretch, Move, Rotate, Scale, Mirror | yes (`canvas.rs:58-67`) |
 | Hot-grip options: Base point, Copy, Undo, Reference, eXit | yes | **advertised in the prompt but not parsed**; the engine's JSON grip commands accept `copy`, the UI never passes it |
@@ -203,6 +203,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-27 | minor | WIPEOUT grips treat implicit and explicit closure alike; moving the first vertex carries the closing marker, and moving endpoints together keeps both corners editable. Open/closed polyline flags and coincident polyline vertices remain independent (#2256) |
 | 2026-10-22 | minor | Polyline segment midpoint grips use rectangles, distinct from square vertex grips, with matching hover and pick bounds (#1950) |
 | 2026-10-13 | minor | Filled-region highlight boundaries are traversed in deterministic coordinate order, fixing dash-phase jumps on CPU hover/selection redraws and GPU highlight rebuilds; CPU dash-position, pixel and GPU mesh regressions |
 | 2026-10-14 | minor | Selection cycling (#1143): the list and badge of SELECTIONCYCLING, Shift+Space through overlapping objects (#1145) |
