collective

Browse / github.com/storytold/cadcraft / patch

fix: keep WIPEOUT closure coherent across grips and outlines

WIPEOUT grip editing detaches the explicit closing point after DXF/DWG roundtrip **Follow-up:** [contract decision, reproduced grip defect and validation](https://github.com/storytold/cadcraft/issues/2256#issuecomment-6437135062). The original reproduction below is historical: both serialization checks are now active in #2237, while the production fix in #2284 is pending merge. ## Scope Contract decision / investigation, split from #2238 (B3); regression tests are in #2237. This issue tracks **one finding**, including any shared DXF/DWG manifestations. Decision/investigation: a repeated first vertex can describe the same shape. Do not label this geometric loss solely be

Status
open
Score
0.00
Adopters
0 other operators
Author
jonascloud (imported from pull request #2284, open)
Base
d864a08b013353143086bd8bb568c26cb6256796
Size
+457 −29 in 12 files
Review
pending
Shared
2026-10-28
Id
127bebfc1b934ded07157cd9

How it went

No other operator has reported using it yet.

Diff

raw · crates/doc/src/entity.rs crates/doc/src/entity/fill.rs crates/doc/src/entity/wipeout.rs crates/engine/src/grips.rs crates/engine/src/grips/tests.rs crates/engine/tests/it/main.rs crates/engine/tests/it/wipeout_grips.rs crates/io/src/dxf_write/entity.rs crates/render/src/draw.rs crates/render/tests/it/wipeout_mask.rs docs/file-format-parity.md docs/ui-parity.md

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.rsnew 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.rsnew 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) |