Files
cadcraft/crates/engine/tests/block_editor.rs
T
d826450e03 Block Editor on the current drawing; a drawing with authoring elements offers it (stacked on #833) (#885)
* Block Editor: BEDIT, BSAVE, BSAVEAS and BCLOSE for static blocks

BEDIT opens a block definition in the drawing's own tab with its base point
at the origin, and the block's objects are edited with the ordinary commands.
BSAVE writes them back into the definition, so every reference in the drawing
updates (one undo step in the drawing); BSAVEAS saves under a new name;
BCLOSE goes back to the drawing, asking whether to save unsaved changes.

- The drawing waits untouched while the editor is open (blockedit.rs): its
  undo history, selection and views come back on BCLOSE. Objects left
  unchanged are saved back exactly, so no coordinate drifts.
- Files save the drawing outside the editor; autosave keeps the editor's
  unsaved changes in the block; the close/quit prompt's Save puts them into
  the block first.
- Right-click a selected block reference: Block Editor. In the editor the
  canvas is tinted, the base point is marked, and a bar offers Save Block,
  Save Block As and Close Block Editor (also in the right-click menu). The
  Block tool set's Block Editor button is now live. BLOCKEDITOR sysvar.
- Layout and paper-space commands are refused while the editor is open; a
  block can't be saved to contain a reference to itself.
- Dynamic blocks: their data is kept; the editor says it can't edit their
  parameters yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Edit Block Definition dialog; BEDIT can start a new block

BEDIT typed or started from a menu or the Tool Sets with no block reference
selected opens the Edit Block Definition dialog: the drawing's blocks sorted
by name, a preview and the description. OK opens the Block Editor on the
block named; a name no block has yet starts a new block, which BSAVE creates
(`bedit {name, create: true}`). A selected block reference still opens at
once, and JSON calls never open the dialog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Edit Block Definition: centred window, list box; gaps.md F3

The dialog opens centred on the window, the blocks are a full-width list
in a list box of fixed height, and gaps.md F3 mentions the dialog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* BEDIT: find dynamic block data on the block's BLOCK_RECORD

A dynamic block's ACAD_ENHANCEDBLOCK dictionary hangs off its BLOCK_RECORD
table entry, not the BLOCK entity, so the "parameters are kept" note never
showed for AutoCAD files. Regression test with the data laid out as an
AutoCAD DXF has it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* BEDIT: a dynamic block reference opens its dynamic block

A dynamic block reference with values of its own shows an anonymous block
(`*U12`), so the right-click Block Editor (and BEDIT on a picked or selected
reference) refused it and asked for a name. The anonymous block's record
names its dynamic block in AcDbBlockRepBTag xdata; BEDIT now opens that, as
AutoCAD does. Regression test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Block Editor on the current drawing; opening a drawing with authoring elements offers it

A dynamic block's own drawing (model space with authoring elements: its
block record's ACAD_ENHANCEDBLOCK) opened from the UI asks, as AutoCAD does,
"<file> contains authoring elements. Open in Block Editor?" Yes / No /
Cancel. Yes opens the Block Editor on <Current Drawing>: the drawing's own
objects, edited where they are; BSAVE keeps them, BCLOSE without saving puts
the drawing back. <Current Drawing> heads the Edit Block Definition list.
BSAVEAS is not available there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Cody Vogt <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-13 06:02:48 -04:00

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);
}