Files
cadcraft/crates/engine/tests/it/oops_command.rs
T
e8bcc38e34 OOPS: bring back the last erase, each object where it was (#2605)
There was no OOPS. It now brings back what the last ERASE removed (the
Delete key too), or BLOCK deleting the objects it makes a block of, as
AutoCAD 2024 does (accoreconsole, the drawing's entnext order read
back):

- Each object keeps its handle and goes back directly above the object
  that was under it, or under the one that was above it when that is
  gone. An erase records both neighbours (EntityStore::neighbours).
- Only the last erase counts; entdel doesn't. OOPS says nothing and is
  one undo step.
- An object stays out when it is already in the drawing, when an undo
  took the drawing back to before it was made, when its layer, block or
  layout is gone, and, during REFEDIT, when it isn't in the working set.
  REFCLOSE forgets the working copies; the Block Editor has an OOPS of
  its own.
- The command's label is in the 17 interface catalogs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-01 14:40:15 -05:00

233 lines
11 KiB
Rust

//! OOPS, as AutoCAD 2024 runs it (accoreconsole: a line A, a line B, a circle C and a line D, these
//! scripts, then the drawing's `entnext` order read back).
use cadcraft_engine::Session;
use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec};
use cadcraft_engine::doc::{Common, EntityKind, Handle, Line, Space};
use cadcraft_engine::geom::Vec3;
use serde_json::{Value, json};
/// A session that `entmake` works in.
fn session() -> Session {
set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups });
Session::new()
}
/// A session with A, B, C and D in draw order, and their handles.
fn drawing() -> (Session, Vec<Handle>) {
let mut s = session();
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
s.execute("line", &json!({ "points": [[0, 10], [10, 10]] })).unwrap();
s.execute("circle", &json!({ "center": [5, 25], "radius": 3 })).unwrap();
s.execute("line", &json!({ "points": [[0, 40], [10, 40]] })).unwrap();
let hs = handles(&s);
(s, hs)
}
fn handles(s: &Session) -> Vec<Handle> {
s.doc().unwrap().model.iter().map(|e| e.handle).collect()
}
/// The drawing's objects in draw order: A to D by their handles, E for any other.
fn order(s: &Session, hs: &[Handle]) -> String {
let name = |h: &Handle| hs.iter().position(|x| x == h).map_or('E', |i| (b'A' + i as u8) as char);
handles(s).iter().map(|h| name(h).to_string()).collect::<Vec<_>>().join(" ")
}
/// Run `script` with `@A`…`@D` standing for the objects.
fn run(s: &mut Session, hs: &[Handle], script: &[&str]) {
for line in script {
let mut line = line.to_string();
for (i, h) in hs.iter().enumerate() {
line = line.replace(&format!("@{}", (b'A' + i as u8) as char), &format!("(handent \"{}\")", h.hex()));
}
s.cmdline(&line).unwrap();
assert!(s.running.is_none(), "{line}: {}", s.prompt_text());
}
}
fn oops(s: &mut Session) -> Value {
s.execute("oops", &json!({})).unwrap()
}
#[test]
fn the_last_erase_comes_back_where_it_was() {
const LINE: &str = "(command \"_.LINE\" '(0.0 50.0) '(10.0 50.0) \"\")";
const OOPS: &str = "(command \"_.OOPS\")";
// (case, script, the drawing's order afterwards: E is the line drawn after the erase)
let cases: [(&str, &[&str], &str); 14] = [
("OOPS-000: nothing erased yet", &[OOPS], "A B C D"),
("OOPS-001: two in the middle", &["(command \"_.ERASE\" @B @C \"\")", OOPS], "A B C D"),
("OOPS-002: picked in reverse", &["(command \"_.ERASE\" @C @B \"\")", OOPS], "A B C D"),
("OOPS-003: a new object after the erase", &["(command \"_.ERASE\" @B \"\")", LINE, OOPS], "A B C D E"),
("OOPS-004: the bottom object", &["(command \"_.ERASE\" @A \"\")", LINE, OOPS], "A B C D E"),
("OOPS-005: twice", &["(command \"_.ERASE\" @B \"\")", OOPS, OOPS], "A B C D"),
("OOPS-006: after U", &["(command \"_.ERASE\" @B \"\")", "U", OOPS], "A B C D"),
("OOPS-007: the last erase only", &["(command \"_.ERASE\" @B \"\")", "(command \"_.ERASE\" @C \"\")", OOPS], "A C D"),
("OOPS-008: U after OOPS", &["(command \"_.ERASE\" @B \"\")", OOPS, "U"], "A C D"),
("OOPS-009: entdel", &["(entdel @B)", OOPS], "A C D"),
("OOPS-010: -BLOCK", &["(command \"_.-BLOCK\" \"BKO\" '(0.0 0.0) @A @C \"\")", OOPS], "A B C D"),
("OOPS-011: U of another command", &["(command \"_.ERASE\" @B \"\")", LINE, "U", OOPS], "A B C D"),
("OOPS-012: the object under it erased since", &["(command \"_.ERASE\" @B \"\")", "(entdel @A)", OOPS], "B C D"),
("OOPS-013: everything", &["(command \"_.ERASE\" \"_ALL\" \"\")", OOPS], "A B C D"),
];
for (case, script, want) in cases {
let (mut s, hs) = drawing();
run(&mut s, &hs, script);
assert_eq!(order(&s, &hs), want, "{case}");
}
}
#[test]
fn oops_says_nothing_and_counts_what_came_back() {
let (mut s, hs) = drawing();
s.cmdline("oops").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("Command: OOPS"));
s.execute("erase", &json!({ "handles": [hs[1].hex(), hs[3].hex()] })).unwrap();
s.cmdline("oops").unwrap();
assert_eq!(s.log.last().map(String::as_str), Some("Command: OOPS"));
assert_eq!(order(&s, &hs), "A B C D");
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
s.execute("u", &json!({})).unwrap();
assert_eq!(order(&s, &hs), "A C");
assert_eq!(oops(&mut s), json!({ "restored": 2 }));
// They come back as they were.
let d = s.doc().unwrap();
assert!(matches!(d.entity(hs[3]).map(|e| &e.kind), Some(EntityKind::Line(l)) if l.a.y == 40.0 && l.b.x == 10.0));
}
#[test]
fn the_delete_key_and_block_feed_oops_too() {
// Edit > Clear, the Delete key.
let (mut s, hs) = drawing();
s.set_selection(vec![hs[2]]);
s.execute("erase.selection", &json!({})).unwrap();
assert_eq!(order(&s, &hs), "A B D");
assert_eq!(oops(&mut s), json!({ "restored": 1 }));
assert_eq!(order(&s, &hs), "A B C D");
// BLOCK deleting its objects.
let (mut s, hs) = drawing();
s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex(), hs[3].hex()], "keep": "delete" })).unwrap();
assert_eq!(order(&s, &hs), "B C");
assert_eq!(oops(&mut s), json!({ "restored": 2 }));
assert_eq!(order(&s, &hs), "A B C D");
}
#[test]
fn an_object_an_undo_took_away_stays_away() {
// U past the line's creation: its handle is free again.
let mut s = session();
s.execute("line", &json!({ "points": [[0, 0], [10, 0]] })).unwrap();
let x = handles(&s)[0];
s.execute("erase", &json!({ "handles": [x.hex()] })).unwrap();
s.execute("u", &json!({})).unwrap();
s.execute("u", &json!({})).unwrap();
assert!(handles(&s).is_empty());
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
assert!(handles(&s).is_empty());
// A new object takes the handle.
s.execute("circle", &json!({ "center": [0, 0], "radius": 1 })).unwrap();
assert_eq!(handles(&s), [x]);
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
assert!(matches!(s.doc().unwrap().entity(x).map(|e| &e.kind), Some(EntityKind::Circle(_))));
// A block's object takes the handle: one erased object of two, one of a block's two.
for (erased, in_block) in [(2, 1), (1, 2)] {
let mut s = session();
for y in 0..erased {
s.execute("line", &json!({ "points": [[0, y], [10, y]] })).unwrap();
}
let hs = handles(&s);
s.execute("erase", &json!({ "handles": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() })).unwrap();
for _ in 0..=erased {
s.execute("u", &json!({})).unwrap();
}
assert!(handles(&s).is_empty());
s.cmdline("(entmake '((0 . \"BLOCK\") (2 . \"BX\") (10 0.0 0.0 0.0) (70 . 0)))").unwrap();
for _ in 0..in_block {
s.cmdline("(entmake '((0 . \"LINE\") (8 . \"0\") (10 0.0 0.0 0.0) (11 1.0 0.0 0.0)))").unwrap();
}
s.cmdline("(entmake '((0 . \"ENDBLK\")))").unwrap();
assert!(s.doc().unwrap().block("BX").is_some_and(|b| b.entities.contains(hs[0])), "{erased} {in_block}");
assert_eq!(oops(&mut s), json!({ "restored": 0 }), "{erased} {in_block}");
assert!(handles(&s).is_empty());
}
}
#[test]
fn an_object_whose_layer_or_block_is_gone_stays_away() {
// Its layer purged.
let (mut s, hs) = drawing();
run(&mut s, &hs, &["(command \"_.-LAYER\" \"_M\" \"L1\" \"\")"]);
s.execute("line", &json!({ "points": [[0, 60], [10, 60]] })).unwrap();
let on_l1 = *handles(&s).last().unwrap();
run(&mut s, &hs, &["(setvar \"CLAYER\" \"0\")"]);
s.execute("erase", &json!({ "handles": [on_l1.hex()] })).unwrap();
s.execute("purge", &json!({ "type": "layers", "names": "L1" })).unwrap();
assert!(s.doc().unwrap().layer("L1").is_none());
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
// Its block purged.
let (mut s, hs) = drawing();
s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex()], "keep": "retain" })).unwrap();
s.execute("insert", &json!({ "name": "BK", "at": [50, 0] })).unwrap();
let ins = *handles(&s).last().unwrap();
s.execute("erase", &json!({ "handles": [ins.hex()] })).unwrap();
s.execute("purge", &json!({ "type": "blocks", "names": "BK" })).unwrap();
assert!(s.doc().unwrap().block("BK").is_none());
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
assert_eq!(order(&s, &hs), "A B C D");
}
#[test]
fn the_block_editor_has_an_oops_of_its_own() {
let (mut s, hs) = drawing();
s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex()], "keep": "retain" })).unwrap();
s.execute("erase", &json!({ "handles": [hs[1].hex()] })).unwrap();
s.execute("bedit", &json!({ "name": "BK" })).unwrap();
// The drawing's erased line doesn't come back into the block.
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
s.execute("bclose", &json!({ "save": false })).unwrap();
// Back in the drawing, it does.
assert_eq!(oops(&mut s), json!({ "restored": 1 }));
assert_eq!(order(&s, &hs), "A B C D");
}
#[test]
fn a_layouts_objects_come_back_to_it() {
let mut s = session();
let paper = Space::Paper("Layout1".into());
let line = |y: f64| EntityKind::Line(Line { a: Vec3::new(0.0, y, 0.0), b: Vec3::new(10.0, y, 0.0) });
let hs: Vec<Handle> = (0..3).map(|y| s.doc_mut().unwrap().add(&paper, Common::default(), line(f64::from(y))).unwrap()).collect();
s.execute("erase", &json!({ "handles": [hs[1].hex()] })).unwrap();
assert_eq!(oops(&mut s), json!({ "restored": 1 }));
let d = s.doc().unwrap();
assert!(d.model.is_empty());
assert_eq!(d.space(&paper).unwrap().handles(), hs);
}
#[test]
fn a_reference_edited_in_place_keeps_its_objects_and_the_drawings_apart() {
let (mut s, hs) = drawing();
s.execute("block", &json!({ "name": "BK", "base": [0, 0], "handles": [hs[0].hex(), hs[1].hex()], "keep": "convert" })).unwrap();
let ins = *handles(&s).last().unwrap();
s.execute("erase", &json!({ "handles": [hs[3].hex()] })).unwrap();
let r = s.execute("refedit", &json!({ "handle": ins.hex() })).unwrap();
// The drawing's line stays out while the reference is edited...
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
// ...and a working copy comes back.
let copy = r["working"][0].as_str().unwrap().to_string();
s.execute("erase", &json!({ "handles": [copy] })).unwrap();
assert_eq!(oops(&mut s), json!({ "restored": 1 }));
// Once the edit is over, its copies don't come back into the drawing.
s.execute("erase", &json!({ "handles": [copy] })).unwrap();
s.execute("refclose", &json!({ "save": true })).unwrap();
assert_eq!(s.doc().unwrap().block("BK").unwrap().entities.len(), 1);
assert_eq!(oops(&mut s), json!({ "restored": 0 }));
assert_eq!(order(&s, &hs), "C E");
// The drawing's own erased line does, after an edit that ends.
s.execute("erase", &json!({ "handles": [hs[2].hex()] })).unwrap();
s.execute("refedit", &json!({ "handle": ins.hex() })).unwrap();
s.execute("refclose", &json!({ "save": false })).unwrap();
assert_eq!(oops(&mut s), json!({ "restored": 1 }));
assert_eq!(order(&s, &hs), "C E");
}