373 lines
17 KiB
Rust
373 lines
17 KiB
Rust
//! 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<Line> {
|
|
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<String> {
|
|
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<y"] {
|
|
assert!(s.execute("bedit", &json!({ "name": bad, "create": true })).is_err(), "{bad}");
|
|
}
|
|
assert!(s.state().unwrap().block_editor().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn the_current_drawing_opens_in_the_block_editor() {
|
|
use cadcraft_engine::blockedit::{CURRENT_DRAWING, has_authoring_elements};
|
|
use cadcraft_engine::doc::foreign::{Foreign, ForeignExtra, ForeignObject};
|
|
use cadcraft_engine::doc::{Handle, RawTag};
|
|
let mut s = Session::new();
|
|
line(&mut s, [0.0, 0.0], [10.0, 0.0]);
|
|
// A dynamic block's own drawing: model space's record carries ACAD_ENHANCEDBLOCK.
|
|
let mut d = s.doc().unwrap().clone();
|
|
assert!(!has_authoring_elements(&d));
|
|
let mut f = Foreign::default();
|
|
let tag = |code, value: &str| RawTag { code, value: value.into() };
|
|
f.records.insert(("BLOCK_RECORD".into(), "*MODEL_SPACE".into()), ForeignExtra { xdict: Some(Handle(0xF00)), ..Default::default() });
|
|
f.objects.push(ForeignObject { handle: Handle(0xF00), kind: "DICTIONARY".into(), tags: vec![tag(3, "ACAD_ENHANCEDBLOCK"), tag(360, "F01")] });
|
|
d.foreign = std::sync::Arc::new(f);
|
|
assert!(has_authoring_elements(&d));
|
|
s.open_drawing(d, "symbol", None);
|
|
|
|
// Opened on the drawing itself: its objects are there to edit, and it says it is dynamic.
|
|
let r = s.execute("bedit", &json!({ "name": "<current drawing>", "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);
|
|
}
|