fix: keep WIPEOUT closure coherent across grips and outlines (#2284)

* fix: keep WIPEOUT closure coherent across grips and outlines

Treat WIPEOUT as a closed polygon with an optional exact terminal closure marker, not as an open polyline. Centralize logical vertices, explicit outlines and closure-preserving vertex edits in the document type. Reuse this contract in grips, geometric primitives, rendering and DXF export without changing the stored schema or writer closure convention.

Add six-version DXF ASCII/binary and DWG regressions for implicit/explicit closure, both windings, all corners, API/interactive edits, undo/redo and repeated saves. Keep open/closed polylines and their coincident endpoints distinct. Cover multiple hot grips, hostile inputs and equivalent mask/frame output.

Validation: cargo xtask ci, all seven stages passed (2571 tests passed, one pre-existing skip). Two unrelated network tests failed transiently in earlier runs; each passed in isolation and the complete final run passed without exclusions. Direct and reopened grip edits produce byte-identical CLI PNG output.

Fixes #2256; related test-only matrix PR #2237 and tracker #2238 B3.

* fix(doc): keep coincident WIPEOUT endpoints separately editable

When an edit makes the endpoints of an implicit boundary coincide, add a separate closing marker rather than reclassifying the edited corner as that marker. Retain the logical vertex count and allow either coincident corner to move independently afterwards.

The document regression fails before the fix. Add API/interactive coverage for both endpoint directions and both closure forms, undo/redo, and subsequent separate edits across all six DXF/DWG versions including binary DXF and save-after-edit. Update the documented editing contract.

Validation: cargo xtask ci passed all seven stages; 2573 tests passed, one pre-existing skip.

Addresses the coincident-endpoint review on PR #2284; related #2256.

---------

Co-authored-by: cloudj <jonbahnes@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Jonas
2026-10-29 21:31:17 -04:00
committed by GitHub
co-authored by cloudj Brandon Thomas
parent 0d9e62bc9a
commit dcbcb6ab32
12 changed files with 455 additions and 28 deletions
+5 -7
View File
@@ -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(),
-5
View File
@@ -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>,
}
+162
View File
@@ -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);
}
}
}
}
+1 -1
View File
@@ -239,7 +239,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)
+15
View File
@@ -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();
+1
View File
@@ -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;
+227
View File
@@ -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);
}
+2 -7
View File
@@ -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));
}
}
+1 -5
View File
@@ -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());
}
}
+37
View File
@@ -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 -1
View File
@@ -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 |
+3 -2
View File
@@ -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 |