//! The Block Editor (BEDIT, BSAVE, BSAVEAS, BCLOSE): a block is edited with the ordinary //! commands, its base point at the origin, and saving it updates every reference in the drawing. use cadcraft_engine::Session; use cadcraft_engine::cmd::file::{IoHooks, set_io}; use cadcraft_engine::doc::{EntityKind, Line}; use cadcraft_engine::geom::Vec3; use serde_json::{Value, json}; fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Value { s.execute("line", &json!({ "points": [a, b] })).unwrap()["handles"][0].clone() } /// A drawing with block "Door" (one line from (10,10) to (12,10), base point (10,10)) and two /// references to it; returns the references. fn door(s: &mut Session) -> (Value, Value) { let l = line(s, [10.0, 10.0], [12.0, 10.0]); let a = s.execute("block", &json!({ "name": "Door", "base": [10, 10], "handles": [l] })).unwrap()["insert"].clone(); let b = s.execute("insert", &json!({ "name": "Door", "at": [50, 0] })).unwrap()["handle"].clone(); (a, b) } fn lines_of(s: &Session, block: &str) -> Vec { let b = s.doc().unwrap().block(block).unwrap().clone(); b.entities.iter().filter_map(|e| if let EntityKind::Line(l) = &e.kind { Some(l.clone()) } else { None }).collect() } fn editor(s: &Session) -> Option { s.state().unwrap().block_editor().map(str::to_string) } #[test] fn saving_the_block_updates_every_reference() { let mut s = Session::new(); let (a, b) = door(&mut s); let model = s.doc().unwrap().model.clone(); s.execute("bedit", &json!({ "name": "door" })).unwrap(); assert_eq!(editor(&s).as_deref(), Some("Door")); // The editor shows the block's objects with its base point at the origin. let shown: Vec<_> = s.doc().unwrap().model.iter().map(|e| e.kind.clone()).collect(); assert!(matches!(&shown[..], [EntityKind::Line(l)] if l.a == Vec3::new(0.0, 0.0, 0.0) && l.b == Vec3::new(2.0, 0.0, 0.0)), "{shown:?}"); assert_eq!(s.execute("getvar", &json!({ "name": "BLOCKEDITOR" })).unwrap()["value"], json!(1)); line(&mut s, [0.0, 0.0], [0.0, 3.0]); assert!(s.state().unwrap().block_unsaved() && s.state().unwrap().is_dirty()); let r = s.execute("bclose", &json!({})).unwrap(); assert_eq!(r, json!({ "block": "Door", "saved": true })); assert_eq!(editor(&s), None); // The new line is in the definition, in block coordinates; the drawing's own objects and both // references are as they were. let ls = lines_of(&s, "Door"); assert_eq!(ls.len(), 2); assert_eq!((ls[1].a, ls[1].b), (Vec3::new(10.0, 10.0, 0.0), Vec3::new(10.0, 13.0, 0.0))); let d = s.doc().unwrap(); assert!(d.model.same_as(&model) || d.model == model); for h in [&a, &b] { let h = cadcraft_engine::cmd::curves::handle_value(h).unwrap(); assert!(matches!(&d.entity(h).unwrap().kind, EntityKind::Insert(i) if i.block == "Door")); } // One undo step in the drawing takes the edit back. s.execute("undo", &json!({})).unwrap(); assert_eq!(lines_of(&s, "Door").len(), 1); } #[test] fn closing_without_saving_leaves_the_drawing_as_it_was() { let mut s = Session::new(); door(&mut s); let before = s.doc().unwrap().clone(); let undo = s.state().unwrap().undo.len(); s.execute("bedit", &json!({ "name": "Door" })).unwrap(); s.script("ERASE ALL\n\n").unwrap(); assert!(s.doc().unwrap().model.is_empty()); s.execute("bclose", &json!({ "save": false })).unwrap(); assert_eq!(*s.doc().unwrap(), before); assert_eq!(s.state().unwrap().undo.len(), undo); assert_eq!(lines_of(&s, "Door").len(), 1); } #[test] fn unchanged_objects_keep_their_exact_coordinates() { let mut s = Session::new(); // A base point whose coordinates don't survive x - b + b exactly. let l = line(&mut s, [0.1, 0.7], [0.3, 0.9]); s.execute("block", &json!({ "name": "B", "base": [0.7, 0.1], "handles": [l] })).unwrap(); let original = lines_of(&s, "B"); s.execute("bedit", &json!({ "name": "B" })).unwrap(); line(&mut s, [0.0, 0.0], [1.0, 0.0]); s.execute("bsave", &json!({})).unwrap(); assert!(!s.state().unwrap().block_unsaved()); assert_eq!(editor(&s).as_deref(), Some("B"), "BSAVE keeps the editor open"); assert_eq!(lines_of(&s, "B")[0], original[0]); s.execute("bclose", &json!({})).unwrap(); let ls = lines_of(&s, "B"); assert_eq!(ls[0], original[0]); assert_eq!(ls.len(), 2); } #[test] fn bsaveas_makes_a_new_block_and_keeps_the_old_one() { let mut s = Session::new(); door(&mut s); s.execute("bedit", &json!({ "name": "Door" })).unwrap(); line(&mut s, [0.0, 0.0], [0.0, 3.0]); let r = s.execute("bsaveas", &json!({ "name": "Door2" })).unwrap(); assert_eq!(r["block"], "Door2"); assert_eq!(editor(&s).as_deref(), Some("Door2")); assert!(s.execute("bsaveas", &json!({ "name": "Door" })).is_err(), "an existing block is redefined only with replace"); assert!(s.execute("bsaveas", &json!({ "name": "bad*name" })).is_err()); s.execute("bclose", &json!({})).unwrap(); assert_eq!(lines_of(&s, "Door").len(), 1); let new = lines_of(&s, "Door2"); assert_eq!(new.len(), 2); assert_eq!(s.doc().unwrap().block("Door2").unwrap().base, s.doc().unwrap().block("Door").unwrap().base); // Redefining another block (typed in another case) keeps its name and takes the edited // block's base point, so the objects keep their places relative to it. let l = line(&mut s, [100.0, 100.0], [101.0, 100.0]); s.execute("block", &json!({ "name": "Other", "base": [100, 100], "handles": [l] })).unwrap(); s.execute("bedit", &json!({ "name": "Door" })).unwrap(); s.execute("bsaveas", &json!({ "name": "OTHER", "replace": true })).unwrap(); s.execute("bclose", &json!({})).unwrap(); let d = s.doc().unwrap(); let other = d.block("Other").unwrap(); assert_eq!(other.name, "Other"); assert_eq!(other.base, d.block("Door").unwrap().base); assert_eq!(lines_of(&s, "Other"), lines_of(&s, "Door")); } #[test] fn what_the_block_editor_refuses() { let mut s = Session::new(); door(&mut s); assert!(s.execute("bedit", &json!({ "name": "Nope" })).is_err()); assert!(s.execute("bedit", &json!({})).is_err(), "no name and no block reference selected"); assert!(s.execute("bsave", &json!({})).is_err(), "not open"); assert!(s.execute("bclose", &json!({})).is_err(), "not open"); s.execute("bedit", &json!({ "name": "Door" })).unwrap(); assert!(s.execute("bedit", &json!({ "name": "Door" })).is_err(), "already open"); let r = s.execute("layout.set", &json!({ "name": "Layout1" })); assert!(r.is_err_and(|e| e.to_string().contains("Block Editor"))); assert_eq!(s.layout_space(), cadcraft_engine::doc::Space::Model); // A reference to the block itself can't be saved into it. s.execute("insert", &json!({ "name": "Door", "at": [5, 5] })).unwrap(); assert!(s.execute("bsave", &json!({})).is_err_and(|e| e.to_string().contains("itself"))); assert!(s.execute("bclose", &json!({})).is_err()); assert!(editor(&s).is_some(), "a failed save keeps the editor open"); s.execute("bclose", &json!({ "save": false })).unwrap(); assert_eq!(lines_of(&s, "Door").len(), 1); // Anonymous blocks are made by other objects. let mut d = s.doc().unwrap().clone(); d.blocks.insert("*U1".into(), std::sync::Arc::new(cadcraft_engine::doc::Block::new("*U1"))); s.open_drawing(d, "anon", None); assert!(s.execute("bedit", &json!({ "name": "*U1" })).is_err_and(|e| e.to_string().contains("anonymous"))); } #[test] fn hostile_params_never_panic() { let mut s = Session::new(); door(&mut s); for p in [json!({ "name": 5 }), json!({ "handle": "zz" }), json!({ "handle": -1 }), json!({ "handle": "FFFFFFFFFFFFFFFFFF" }), json!(null), json!([])] { assert!(s.execute("bedit", &p).is_err(), "{p}"); } s.execute("bedit", &json!({ "name": "Door" })).unwrap(); for p in [json!({ "name": "" }), json!({ "name": " " }), json!({ "name": 7 }), json!({ "name": "x".repeat(300) })] { assert!(s.execute("bsaveas", &p).is_err(), "{p}"); } assert!(s.execute("bclose", &json!({ "save": "maybe" })).is_ok()); } #[test] fn a_selected_reference_opens_its_block_and_bclose_asks_to_save() { let mut s = Session::new(); let (a, _) = door(&mut s); s.set_selection(vec![cadcraft_engine::cmd::curves::handle_value(&a).unwrap()]); s.start("bedit").unwrap(); assert!(s.running.is_none()); assert_eq!(editor(&s).as_deref(), Some("Door")); // Nothing changed: BCLOSE closes at once. s.script("BCLOSE\n").unwrap(); assert_eq!(editor(&s), None); // Typed name, a change, then BCLOSE No discards it. s.script("BEDIT Door\nLINE 0,0 5,5\n\nBCLOSE\nN\n").unwrap(); assert_eq!(editor(&s), None); assert_eq!(lines_of(&s, "Door").len(), 1); // BCLOSE Yes saves it. s.script("BEDIT Door\nLINE 0,0 5,5\n\nBCLOSE\n\n").unwrap(); assert_eq!(lines_of(&s, "Door").len(), 2); assert_eq!(s.execute("getvar", &json!({ "name": "BLOCKEDITOR" })).map(|v| v["value"].clone()).unwrap_or(Value::Null), json!(0)); } #[test] fn a_file_save_writes_the_drawing_outside_the_editor() { // The writer records how many objects the drawing's model space has, and block Door's. set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |d, _| Ok(format!("{} {}", d.model.len(), d.block("Door").map_or(0, |b| b.entities.len())).into_bytes()), plot: None, }); let mut s = Session::new(); door(&mut s); let path = std::env::temp_dir().join(format!("cadcraft-bedit-{}.dxf", std::process::id())); let path = path.to_string_lossy().to_string(); s.execute("saveas", &json!({ "path": path })).unwrap(); assert!(!s.state().unwrap().is_dirty()); s.execute("bedit", &json!({ "name": "Door" })).unwrap(); line(&mut s, [0.0, 0.0], [0.0, 3.0]); // Autosave keeps the editor's unsaved change in the block. let rec = s.state().unwrap().recovery_drawing(); assert_eq!(rec.block("Door").unwrap().entities.len(), 2); assert_eq!(rec.model.len(), 2); s.execute("qsave", &json!({})).unwrap(); assert_eq!(std::fs::read_to_string(&path).unwrap(), "2 1", "the drawing, without the unsaved block change"); assert!(s.state().unwrap().is_dirty(), "the block change is still unsaved"); s.execute("bsave", &json!({})).unwrap(); s.execute("qsave", &json!({})).unwrap(); assert_eq!(std::fs::read_to_string(&path).unwrap(), "2 2"); assert!(!s.state().unwrap().is_dirty()); s.execute("bclose", &json!({})).unwrap(); assert!(!s.state().unwrap().is_dirty()); let _ = std::fs::remove_file(&path); } #[test] fn a_dynamic_block_from_another_program_says_its_parameters_are_kept() { use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra, ForeignObject}; use cadcraft_engine::doc::{Handle, RawTag}; let mut s = Session::new(); door(&mut s); // As a DXF from AutoCAD has it: the block record's extension dictionary holds ACAD_ENHANCEDBLOCK. let mut f = Foreign::default(); let xdict = Handle(0xF00); f.records.insert(("BLOCK_RECORD".into(), "DOOR".into()), ForeignExtra { xdict: Some(xdict), ..Default::default() }); let tag = |code, value: &str| RawTag { code, value: value.into() }; f.objects.push(ForeignObject { handle: xdict, kind: "DICTIONARY".into(), tags: vec![tag(3, "ACAD_ENHANCEDBLOCK"), tag(360, "F01")] }); let mut d = s.doc().unwrap().clone(); d.foreign = std::sync::Arc::new(f); s.open_drawing(d, "dynamic", None); s.execute("bedit", &json!({ "name": "Door" })).unwrap(); assert!(s.log.iter().any(|l| l.contains("dynamic block")), "{:?}", s.log); // A plain block says nothing of the kind. s.execute("bclose", &json!({})).unwrap(); let mut s = Session::new(); door(&mut s); s.execute("bedit", &json!({ "name": "Door" })).unwrap(); assert!(!s.log.iter().any(|l| l.contains("dynamic block")), "{:?}", s.log); } #[test] fn a_dynamic_reference_with_its_own_values_opens_its_dynamic_block() { use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra}; use cadcraft_engine::doc::{Block, Handle, RawTag}; let mut s = Session::new(); let (a, _) = door(&mut s); // As AutoCAD leaves it: the reference shows anonymous block *U7, whose record's // AcDbBlockRepBTag xdata names Door's record (file handle 1D). let mut d = s.doc().unwrap().clone(); let door_block = d.block("Door").unwrap().clone(); d.blocks.insert("*U7".into(), std::sync::Arc::new(Block { name: "*U7".into(), anonymous: true, ..(*door_block).clone() })); let h = cadcraft_engine::cmd::curves::handle_value(&a).unwrap(); d.model.modify(h, |e| { if let EntityKind::Insert(i) = &mut e.kind { i.block = "*U7".into(); } }); let mut f = Foreign::default(); let tag = |code, value: &str| RawTag { code, value: value.into() }; f.records.insert( ("BLOCK_RECORD".into(), "*U7".into()), ForeignExtra { xdata: vec![tag(1001, "AcDbBlockRepBTag"), tag(1070, "1"), tag(1005, "1D")], ..Default::default() }, ); f.targets.insert(Handle(0x1D), ("BLOCK_RECORD".into(), "DOOR".into())); d.foreign = std::sync::Arc::new(f); s.open_drawing(d, "dynamic", None); // Selected (the right-click Block Editor) and by handle: both open Door. s.set_selection(vec![h]); s.start("bedit").unwrap(); assert_eq!(editor(&s).as_deref(), Some("Door")); s.execute("bclose", &json!({})).unwrap(); s.execute("bedit", &json!({ "handle": h.hex() })).unwrap(); assert_eq!(editor(&s).as_deref(), Some("Door")); s.execute("bclose", &json!({})).unwrap(); // Other anonymous blocks are still refused by name. assert!(s.execute("bedit", &json!({ "name": "*U7" })).is_err()); } #[test] fn bedit_can_start_a_new_block() { let mut s = Session::new(); door(&mut s); // An existing name edits that block. let r = s.execute("bedit", &json!({ "name": "door", "create": true })).unwrap(); assert_eq!(r, json!({ "block": "Door", "new": false })); s.execute("bclose", &json!({})).unwrap(); // A new name starts an empty block; closing without saving creates nothing. let r = s.execute("bedit", &json!({ "name": " Window ", "create": true })).unwrap(); assert_eq!(r, json!({ "block": "Window", "new": true })); assert!(s.doc().unwrap().model.is_empty()); s.execute("bclose", &json!({ "save": false })).unwrap(); assert!(s.doc().unwrap().block("Window").is_none()); // Saving creates it, base point at the origin. s.execute("bedit", &json!({ "name": "Window", "create": true })).unwrap(); line(&mut s, [0.0, 0.0], [3.0, 0.0]); s.execute("bclose", &json!({})).unwrap(); let d = s.doc().unwrap(); let w = d.block("Window").unwrap(); assert_eq!((w.name.as_str(), w.entities.len(), w.base), ("Window", 1, Vec3::new(0.0, 0.0, 0.0))); // Without `create` a missing block is an error; invalid new names are refused. assert!(s.execute("bedit", &json!({ "name": "Skylight" })).is_err()); for bad in ["", "a*b", "*U9", "x", "create": true })).unwrap(); assert_eq!(r, json!({ "block": CURRENT_DRAWING, "new": false })); assert_eq!(editor(&s).as_deref(), Some(CURRENT_DRAWING)); assert_eq!(s.doc().unwrap().model.len(), 1); assert!(s.log.iter().any(|l| l.contains("dynamic block")), "{:?}", s.log); // A block of it can't be saved under another name here. assert!(s.execute("bsaveas", &json!({ "name": "Copy" })).is_err()); // BCLOSE without saving puts the drawing back. line(&mut s, [0.0, 5.0], [10.0, 5.0]); s.execute("bclose", &json!({ "save": false })).unwrap(); assert_eq!(editor(&s), None); assert_eq!(s.doc().unwrap().model.len(), 1); // Saved, the change is the drawing's: one undo step takes it back. s.execute("bedit", &json!({ "name": CURRENT_DRAWING })).unwrap(); line(&mut s, [0.0, 5.0], [10.0, 5.0]); s.execute("bclose", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().model.len(), 2); assert!(s.doc().unwrap().block(CURRENT_DRAWING).is_none(), "no block is made"); s.undo().unwrap(); assert_eq!(s.doc().unwrap().model.len(), 1); }