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
@@ -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(),@@ -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>,-}new file mode 100644@@ -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);+ }+ }+ }+}@@ -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)@@ -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();@@ -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;new file mode 100644@@ -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(¤t, format, &options).unwrap();+ current = cadcraft_io::read(&bytes, format).unwrap();+ assert!(current.read_notes.is_empty() && current.skipped_records.is_empty());+ assert_boundary(¤t, 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);+}@@ -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)); } }@@ -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) => {@@ -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.@@ -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 |@@ -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) |