diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 089904109..d13b82722 100644 --- 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 index 6790b0a7c..cff172a71 100644 --- 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, -} diff --git a/crates/doc/src/entity/wipeout.rs b/crates/doc/src/entity/wipeout.rs new file mode 100644 index 000000000..c79e93275 --- /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, +} + +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 index a3b8921aa..8c58e30b5 100644 --- a/crates/engine/src/grips.rs +++ b/crates/engine/src/grips.rs @@ -239,7 +239,7 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option *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 index 971937704..2fc4502b6 100644 --- a/crates/engine/src/grips/tests.rs +++ b/crates/engine/src/grips/tests.rs @@ -169,6 +169,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 index 51cec4590..8e20e6fa8 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -144,6 +144,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 index 000000000..e34620089 --- /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) { + 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::>(), 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 index cd41cdf91..9c2f12896 100644 --- a/crates/io/src/dxf_write/entity.rs +++ b/crates/io/src/dxf_write/entity.rs @@ -665,14 +665,9 @@ fn write_wipeout_entity(w: &mut W, _d: &Drawing, e: &Entity, owner: &str, paper: 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 index 04e2651c5..cf37e28f7 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -294,11 +294,7 @@ fn draw_wipeout(b: &mut Builder, ctx: &Ctx, w: &cadcraft_doc::Wipeout, rgb: Ink, _ => 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()); } } diff --git a/crates/render/tests/it/wipeout_mask.rs b/crates/render/tests/it/wipeout_mask.rs index 508b052b6..6c9531988 100644 --- 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 index 2bfea4a51..291c9b202 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6bf0c20f8..3522b5091 100644 --- 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-26 · **Change:** minor (browser panel layout storage) · **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 | @@ -204,6 +204,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-26 | minor | Browser host stores bounded, validated panel layout separately from drawing state; malformed or unsupported records retain defaults. Native workspace storage is unchanged. | | 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 |