diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index f95a99b93..2b7fc0ba7 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -74,6 +74,7 @@ pub fn specs() -> Vec { attribs::attdisp_spec(), attribs::attsync_spec(), burst::burst_spec(), + blockreplace::blockreplace_spec(), extract::dataextraction_spec(), extract::eattext_spec(), ] @@ -88,12 +89,19 @@ pub(crate) fn valid_block_name(n: &str) -> bool { !n.trim().is_empty() && n.len() <= 255 && !n.starts_with('*') && !n.chars().any(|c| "<>/\\\":;?*|,=`".contains(c)) } -/// The block an entity references, when it is a block reference. +/// The block an entity draws, if any: a block reference's block, a dimension's block or a +/// multileader's block content. fn referenced_block(e: &Entity) -> Option { - if let EntityKind::Insert(i) = &e.kind { Some(i.block.clone()) } else { None } + match &e.kind { + EntityKind::Insert(i) => Some(i.block.clone()), + EntityKind::Dimension(dm) => dm.block.clone(), + EntityKind::MLeader(m) => m.block.as_ref().map(|b| b.block.clone()), + _ => None, + } } -/// Whether any of `ents` refers to block `name`, directly or through nested block references. +/// Whether any of `ents` refers to block `name`, directly or through nested block references, +/// dimension blocks and multileader block content. pub(crate) fn references_block(d: &cadcraft_doc::Drawing, ents: &[Entity], name: &str) -> bool { let mut todo: Vec = ents.iter().filter_map(referenced_block).collect(); let mut seen = std::collections::HashSet::new(); @@ -878,3 +886,4 @@ impl Interactive for AttdefM { mod attribs; mod battman; +mod blockreplace; diff --git a/crates/engine/src/cmd/blocks/attribs.rs b/crates/engine/src/cmd/blocks/attribs.rs index 38e1c5c89..7cb53dad5 100644 --- a/crates/engine/src/cmd/blocks/attribs.rs +++ b/crates/engine/src/cmd/blocks/attribs.rs @@ -84,7 +84,7 @@ impl Interactive for AttdispM { /// The attributes `ins` gets from the definitions `defs` of its block `blk`: placed, styled and /// shown as defined, keeping the values of attributes whose tag (any case) is still defined. /// Properties set on single attributes (layer, colour, …) go back to the definitions', as in AutoCAD. -fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec { +pub(super) fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec { let m = ins.transform(blk.base.xy()); defs.iter() .map(|ad| { @@ -119,12 +119,16 @@ fn references<'a>(ents: impl Iterator>, name: &str) -> Ve /// Apply `f` to every reference of block `name` (in model space, layouts and other blocks); /// returns how many references there were. fn update_references(d: &mut cadcraft_doc::Drawing, name: &str, mut f: impl FnMut(&mut Insert)) -> usize { - let mut n = 0; - let mut update = |e: &mut Entity| { + update_reference_entities(d, name, |e| { if let EntityKind::Insert(ins) = &mut e.kind { f(ins); } - }; + }) +} + +/// [`update_references`], with `f` given the whole object. +pub(super) fn update_reference_entities(d: &mut cadcraft_doc::Drawing, name: &str, mut update: impl FnMut(&mut Entity)) -> usize { + let mut n = 0; let spaces: Vec = std::iter::once(cadcraft_doc::Space::Model).chain(d.layouts.iter().map(|l| cadcraft_doc::Space::Paper(l.name.clone()))).collect(); for sp in spaces { @@ -169,18 +173,22 @@ pub(super) fn rename_tag(s: &mut Session, name: &str, old: &str, new: &str) -> R })) } -/// Synchronize every reference of block `name` (in model space, layouts and other blocks). -/// Returns the block's name as defined and how many references were updated. -pub(super) fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> { - let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?; - let defs: Vec<&Attrib> = blk - .entities +/// The attribute definitions of `blk` that references carry (constant ones are not). +pub(super) fn attdefs(blk: &Block) -> Vec<&Attrib> { + blk.entities .iter() .filter_map(|e| match &e.kind { EntityKind::AttDef(a) if !a.constant => Some(a), _ => None, }) - .collect(); + .collect() +} + +/// Synchronize every reference of block `name` (in model space, layouts and other blocks). +/// Returns the block's name as defined and how many references were updated. +pub(super) fn sync(s: &mut Session, name: &str) -> Result<(String, usize)> { + let blk = s.doc()?.block(name).cloned().ok_or_else(|| EngineError::Other(format!("Block \"{name}\" not found.")))?; + let defs = attdefs(&blk); let d = s.doc_mut()?; let n = update_references(d, &blk.name, |ins| ins.attribs = synced(&blk, &defs, ins)); Ok((blk.name.clone(), n)) diff --git a/crates/engine/src/cmd/blocks/blockreplace.rs b/crates/engine/src/cmd/blocks/blockreplace.rs new file mode 100644 index 000000000..c20012784 --- /dev/null +++ b/crates/engine/src/cmd/blocks/blockreplace.rs @@ -0,0 +1,220 @@ +//! BLOCKREPLACE and -BLOCKREPLACE (Express Tools): every reference of one block, in model space, +//! the layouts and other blocks, becomes a reference of another block at the same insertion +//! point, scale and rotation. The unreferenced block definition can then be purged. + +use cadcraft_doc::{Drawing, Entity, EntityKind}; +use serde_json::{Value, json}; + +use super::attribs::{attdefs, synced, update_reference_entities}; +use super::purge::{override_arrows, used_blocks}; +use super::references_block; +use crate::cmd::curves::KW; +use crate::cmd::machines::{SelOutcome, SelectPhase}; +use crate::cmd::{CommandSpec, bad, str_param}; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +pub(super) fn blockreplace_spec() -> CommandSpec { + CommandSpec::new("blockreplace", "Replace Block...", run_blockreplace) + .menu(&["Tools", "Express Tools", "Replace Block..."]) + .alias(&["-blockreplace"]) + .params("{block (the block whose references are replaced), with (the replacement block), purge?: bool (default false: remove the replaced block's definition when nothing references it any more)} → {block, with, replaced, purged}") + .interactive(|_| Ok(Box::new(BlockreplaceM::default()))) +} + +/// The defined name of block `name` (names are case-insensitive), when it is a named block of +/// this drawing: not anonymous and not one an xref brings in (`X|A`), which is never saved. +fn named_block(d: &Drawing, name: &str) -> Option { + d.block(name.trim()).filter(|b| !b.anonymous && !b.name.starts_with('*') && !b.name.contains('|')).map(|b| b.name.clone()) +} + +/// Replace every reference of `old` by a reference of `new`; with `purge`, remove `old`'s +/// definition when nothing references it afterwards. Returns (references replaced, purged). +fn replace(s: &mut Session, old: &str, new: &str, purge: bool) -> Result<(usize, bool)> { + let cmd = "blockreplace"; + if s.state()?.block_edit.is_some() { + return Err(bad(cmd, "the Block Editor is open; close it with BCLOSE first")); + } + let d = s.doc()?; + if d.refedit.is_some() { + return Err(bad(cmd, "a block is being edited in place; finish with REFCLOSE first")); + } + if let Some(n) = [old, new].into_iter().find(|n| n.contains('|')) { + return Err(bad(cmd, format!("Block \"{}\" comes from an xref; bind the xref first.", n.trim()))); + } + let old = named_block(d, old).ok_or_else(|| bad(cmd, format!("Block \"{}\" not found.", old.trim())))?; + let new = named_block(d, new).ok_or_else(|| bad(cmd, format!("Block \"{}\" not found.", new.trim())))?; + if old.eq_ignore_ascii_case(&new) { + return Err(bad(cmd, "the replacement block must be a different block")); + } + // A dynamic block's references keep their property values in data that names the block. + if cadcraft_doc::dynblock::dynamic_block(d, &old).is_some() { + return Err(bad(cmd, format!("Block \"{old}\" is a dynamic block; its references cannot be replaced."))); + } + let blk = d.block(&new).cloned().ok_or_else(|| bad(cmd, format!("Block \"{new}\" not found.")))?; + // References of `old` inside `new` would make `new` reference itself. + if references_block(d, &blk.entities.iter().map(|e| (**e).clone()).collect::>(), &old) { + return Err(bad(cmd, format!("Block \"{new}\" references \"{old}\", so it cannot replace it."))); + } + let defs = attdefs(&blk); + let scale = d.current_anno_scale(); + let d = s.doc_mut()?; + let n = update_reference_entities(d, &old, |e| { + if let EntityKind::Insert(ins) = &mut e.kind { + ins.block.clone_from(&blk.name); + ins.attribs = synced(&blk, &defs, ins); + } + // A reference of an annotative block is annotative, as INSERT makes it. + if !blk.annotative { + e.common.anno_scales.clear(); + } else if e.common.anno_scales.is_empty() { + e.common.anno_scales = vec![scale.clone()]; + } + }); + let key = d.blocks.keys().find(|k| k.eq_ignore_ascii_case(&old)).cloned(); + let purged = purge && !referenced(d, &old) && key.is_some_and(|k| d.blocks.remove(&k).is_some()); + Ok((n, purged)) +} + +/// Whether anything still names block `name`: what PURGE counts as in use, and the dimensions, +/// multileaders and block references of every other block definition, used or not. +fn referenced(d: &Drawing, name: &str) -> bool { + if used_blocks(d).contains(&name.to_ascii_uppercase()) { + return true; + } + let names = |e: &Entity| -> Vec { + match &e.kind { + EntityKind::Insert(i) => vec![i.block.clone()], + EntityKind::Dimension(dm) => dm.block.iter().cloned().chain(override_arrows(dm)).collect(), + EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(), + _ => Vec::new(), + } + }; + d.blocks + .values() + .filter(|b| !b.name.eq_ignore_ascii_case(name)) + .flat_map(|b| b.entities.iter()) + .any(|e| names(e).iter().any(|n| n.trim().eq_ignore_ascii_case(name))) +} + +fn run_blockreplace(s: &mut Session, p: &Value) -> Result { + let cmd = "blockreplace"; + let old = str_param(p, "block").ok_or_else(|| bad(cmd, "`block` (the block to replace) is required"))?; + let new = str_param(p, "with").ok_or_else(|| bad(cmd, "`with` (the replacement block) is required"))?; + let purge = match p.get("purge") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(v) => return Err(bad(cmd, format!("`purge` must be true or false, not {v}"))), + }; + let (old, new) = (old.to_string(), new.to_string()); + let (n, purged) = replace(s, &old, &new, purge)?; + let d = s.doc()?; + let defined = |name: &str| named_block(d, name).unwrap_or_else(|| name.trim().to_string()); + Ok(json!({ "block": defined(&old), "with": defined(&new), "replaced": n, "purged": purged })) +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Stage { + #[default] + Old, + New, + Select, + Purge, +} + +#[derive(Default)] +struct BlockreplaceM { + stage: Stage, + /// The stage a picked reference answers. + back: Stage, + sel: SelectPhase, + old: String, + new: String, +} + +impl BlockreplaceM { + /// Take `name` as the answer to the current name prompt. + fn take(&mut self, s: &Session, stage: Stage, name: &str) -> Result<()> { + let d = s.doc()?; + let name = named_block(d, name).ok_or_else(|| EngineError::Other(format!("Block \"{}\" not found.", name.trim())))?; + if stage == Stage::Old { + self.old = name; + self.stage = Stage::New; + } else { + if name.eq_ignore_ascii_case(&self.old) { + return Err(EngineError::Other("The replacement block must be a different block.".into())); + } + self.new = name; + self.stage = Stage::Purge; + } + Ok(()) + } + + fn finish(&mut self, s: &mut Session, purge: bool) -> Result { + let (n, purged) = replace(s, &self.old, &self.new, purge)?; + s.echo(format!( + "{n} reference{} of block {} replaced with {}.{}", + if n == 1 { "" } else { "s" }, + self.old, + self.new, + if purged { format!(" Block {} purged.", self.old) } else { String::new() } + )); + Ok(Step::Done) + } +} + +impl Interactive for BlockreplaceM { + fn name(&self) -> &'static str { + "BLOCKREPLACE" + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.stage { + Stage::Old => Prompt::new("Enter the name of the block to replace", Accept::TEXT).kw(&["?", "Select"]), + Stage::New => Prompt::new(format!("Enter the name of the replacement block for {}", self.old), Accept::TEXT).kw(&["?", "Select"]), + Stage::Select => Prompt::new("Select a block reference", Accept::SELECT), + Stage::Purge => Prompt::new(format!("Purge the unreferenced block definition {}?", self.old), KW).kw(&["Yes", "No"]).default("Yes"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.stage { + stage @ (Stage::Old | Stage::New) => match &i { + Input::Keyword(k) if k == "?" => { + let d = s.doc()?; + let mut names: Vec = d.blocks.keys().filter_map(|k| named_block(d, k)).collect(); + names.sort_by_key(|n| n.to_ascii_lowercase()); + s.echo(if names.is_empty() { "No blocks defined.".to_string() } else { format!("Blocks: {}", names.join(", ")) }); + } + Input::Keyword(k) if k == "Select" => { + self.back = stage; + self.sel = SelectPhase::single(); + self.stage = Stage::Select; + } + Input::Text(t) if !t.trim().is_empty() => self.take(s, stage, t)?, + Input::Enter | Input::Cancel => return Ok(Step::Done), + _ => {} + }, + Stage::Select => match self.sel.feed(s, &i)? { + SelOutcome::More => {} + SelOutcome::Empty => self.stage = self.back, + SelOutcome::Done(hs) => { + s.set_selection(Vec::new()); + self.sel = SelectPhase::single(); + let block = hs.iter().find_map(|h| match s.doc().ok()?.entity(*h).map(|e| &e.kind) { + Some(EntityKind::Insert(ins)) => Some(ins.block.clone()), + _ => None, + }); + match block { + Some(b) => self.take(s, self.back, &b)?, + None => return Err(EngineError::Other("That object is not a block reference.".into())), + } + } + }, + Stage::Purge => match &i { + Input::Keyword(k) if k == "No" => return self.finish(s, false), + Input::Keyword(_) | Input::Enter => return self.finish(s, true), + Input::Cancel => return Ok(Step::Done), + _ => return Err(EngineError::Other("Invalid option keyword.".into())), + }, + } + Ok(Step::Continue) + } +} diff --git a/crates/engine/src/cmd/blocks/purge.rs b/crates/engine/src/cmd/blocks/purge.rs index 73c0ddc9b..68aea3c36 100644 --- a/crates/engine/src/cmd/blocks/purge.rs +++ b/crates/engine/src/cmd/blocks/purge.rs @@ -119,7 +119,7 @@ fn entities(d: &Drawing) -> impl Iterator impl Iterator + '_ { +pub(in crate::cmd) fn override_arrows(dm: &cadcraft_doc::Dimension) -> impl Iterator + '_ { dm.overrides .iter() .filter(|(k, _)| cadcraft_doc::DimStyle::field_name(k).is_some_and(|f| f.starts_with("arrowBlock") || f == "leaderArrowBlock")) diff --git a/crates/engine/tests/it/block_redefine.rs b/crates/engine/tests/it/block_redefine.rs index e5a9cac16..136d2817d 100644 --- a/crates/engine/tests/it/block_redefine.rs +++ b/crates/engine/tests/it/block_redefine.rs @@ -40,6 +40,12 @@ fn a_block_cannot_be_redefined_to_reference_itself() { let r = s.execute("block", &json!({ "name": "a", "base": [0, 0], "handles": [b] })); assert!(r.is_err_and(|e| e.to_string().contains("references itself"))); assert_eq!(contents(&s, "A"), ["line"]); + + // Through a multileader whose content is the block being redefined. + let m = s.execute("mleader", &json!({ "points": [[0, 0], [10, 5]], "block": "A" })).unwrap()["handle"].clone(); + let r = s.execute("block", &json!({ "name": "A", "base": [0, 0], "handles": [m] })); + assert!(r.is_err_and(|e| e.to_string().contains("references itself"))); + assert_eq!(contents(&s, "A"), ["line"]); } #[test] diff --git a/crates/engine/tests/it/blockreplace.rs b/crates/engine/tests/it/blockreplace.rs new file mode 100644 index 000000000..eefb23f1f --- /dev/null +++ b/crates/engine/tests/it/blockreplace.rs @@ -0,0 +1,186 @@ +//! BLOCKREPLACE (Express Tools): every reference of one block, in model space, the layouts and +//! other blocks, becomes a reference of another at the same insertion point, scale and rotation. + +use cadcraft_engine::doc::geom::Vec3; +use cadcraft_engine::doc::{Block, Common, Entity, EntityKind, Handle, Insert, Space}; +use cadcraft_engine::{Input, Session}; +use serde_json::{Value, json}; + +/// Make block `name` from what is in model space now (the objects are removed). +fn make_block(s: &mut Session, name: &str) { + let hs: Vec = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect(); + s.execute("block", &json!({"name": name, "base": [0, 0], "handles": hs, "keep": "delete"})).unwrap(); +} + +/// Block A: a circle and attribute TAG. Block B: a line, attributes TAG and NEW. Block N holds a +/// reference of A. Model space: a reference of A on layer R at (10, 5), scaled 2 and rotated +/// 30°, with TAG "kept", and a reference of N. Layout1: a reference of A. +fn drawing() -> (Session, Handle) { + let mut s = Session::new(); + s.execute("attdef", &json!({"tag": "TAG", "default": "a", "at": [0, 2], "height": 1.0})).unwrap(); + s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap(); + make_block(&mut s, "A"); + s.execute("attdef", &json!({"tag": "TAG", "default": "b", "at": [0, 3], "height": 1.0})).unwrap(); + s.execute("attdef", &json!({"tag": "NEW", "default": "new", "at": [0, 5], "height": 1.0})).unwrap(); + s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + make_block(&mut s, "B"); + s.execute("insert", &json!({"name": "A", "at": [0, 0]})).unwrap(); + make_block(&mut s, "N"); + s.execute("insert", &json!({"name": "N", "at": [50, 0]})).unwrap(); + s.execute("layer.new", &json!({"name": "R", "current": true})).unwrap(); + let r = s.execute("insert", &json!({"name": "A", "at": [10, 5], "scale": 2, "rotation": 30, "attribs": {"TAG": "kept"}})).unwrap(); + let ins = Insert { + block: "A".into(), + insert: Vec3::new(0.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + s.doc_mut().unwrap().add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Insert(ins)).unwrap(); + (s, Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()) +} + +/// The block names of the references in `ents`. +fn refs<'a>(ents: impl Iterator>) -> Vec { + ents.filter_map(|e| match &e.kind { + EntityKind::Insert(i) => Some(i.block.clone()), + _ => None, + }) + .collect() +} + +fn insert(s: &Session, h: Handle) -> (Insert, String) { + let e = s.doc().unwrap().entity(h).unwrap(); + let EntityKind::Insert(i) = &e.kind else { panic!("not a block reference") }; + (i.clone(), e.common.layer.clone()) +} + +#[test] +fn blockreplace_replaces_every_reference_and_keeps_its_placement() { + let (mut s, h) = drawing(); + let before = insert(&s, h).0; + let r = s.execute("blockreplace", &json!({"block": "a", "with": "b"})).unwrap(); + assert_eq!(r, json!({"block": "A", "with": "B", "replaced": 3, "purged": false})); + + let (after, layer) = insert(&s, h); + assert_eq!(after.block, "B"); + assert_eq!((after.insert, after.scale, after.rotation, layer.as_str()), (before.insert, before.scale, before.rotation, "R")); + // The replacement's attributes, with the value of the tag both blocks define. + let values: Vec<(String, String)> = after.attribs.iter().map(|a| (a.tag.clone(), a.text.value.clone())).collect(); + assert_eq!(values, [("TAG".to_string(), "kept".to_string()), ("NEW".to_string(), "new".to_string())]); + + let d = s.doc().unwrap(); + assert_eq!(refs(d.model.iter()), ["N", "B"]); + assert_eq!(refs(d.block("N").unwrap().entities.iter()), ["B"]); + assert_eq!(refs(d.space(&Space::Paper("Layout1".into())).unwrap().iter()), ["B"]); + assert!(d.block("A").is_some(), "the definition stays without `purge`"); + + s.execute("undo", &json!({})).unwrap(); + assert_eq!(insert(&s, h).0, before); +} + +#[test] +fn blockreplace_references_follow_the_replacement_blocks_annotative_setting() { + let (mut s, h) = drawing(); + let c = s.execute("circle", &json!({"center": [0, 0], "radius": 2})).unwrap()["handle"].clone(); + s.execute("block", &json!({"name": "AN", "base": [0, 0], "handles": [c], "keep": "delete", "annotative": true})).unwrap(); + let scales = |s: &Session| s.doc().unwrap().entity(h).unwrap().common.anno_scales.len(); + assert_eq!(scales(&s), 0); + s.execute("blockreplace", &json!({"block": "A", "with": "AN"})).unwrap(); + assert_eq!(scales(&s), 1, "a reference of an annotative block supports the current scale"); + s.execute("blockreplace", &json!({"block": "AN", "with": "B"})).unwrap(); + assert_eq!(scales(&s), 0); +} + +#[test] +fn blockreplace_purges_the_replaced_definition_on_request() { + let (mut s, _) = drawing(); + let r = s.execute("blockreplace", &json!({"block": "A", "with": "B", "purge": true})).unwrap(); + assert_eq!(r["purged"], json!(true)); + assert!(s.doc().unwrap().block("A").is_none()); +} + +#[test] +fn blockreplace_keeps_a_definition_an_unused_block_still_names() { + let (mut s, _) = drawing(); + // Block U, never inserted, holds a multileader whose content is block A. + let m = s.execute("mleader", &json!({"points": [[0, 0], [10, 5]], "block": "A"})).unwrap()["handle"].clone(); + s.execute("block", &json!({"name": "U", "base": [0, 0], "handles": [m], "keep": "delete"})).unwrap(); + let r = s.execute("blockreplace", &json!({"block": "A", "with": "B", "purge": true})).unwrap(); + assert_eq!((r["replaced"].clone(), r["purged"].clone()), (json!(3), json!(false))); + assert!(s.doc().unwrap().block("A").is_some()); +} + +#[test] +fn blockreplace_refuses_what_it_cannot_replace() { + let (mut s, _) = drawing(); + let err = |s: &mut Session, p: Value| s.execute("blockreplace", &p).unwrap_err().to_string(); + assert!(err(&mut s, json!({"block": "A", "with": "a"})).contains("different block")); + assert!(err(&mut s, json!({"block": "A", "with": "MISSING"})).contains("not found")); + assert!(err(&mut s, json!({"block": "MISSING", "with": "B"})).contains("not found")); + // N holds a reference of A, so N replacing A would reference itself. + assert!(err(&mut s, json!({"block": "A", "with": "N"})).contains("references")); + // M holds a multileader whose content is N, so M reaches A too. + let m = s.execute("mleader", &json!({"points": [[0, 0], [10, 5]], "block": "N"})).unwrap()["handle"].clone(); + s.execute("block", &json!({"name": "M", "base": [0, 0], "handles": [m], "keep": "delete"})).unwrap(); + assert!(err(&mut s, json!({"block": "A", "with": "M"})).contains("references")); + assert!(err(&mut s, json!({"block": "A", "with": "B", "purge": "yes"})).contains("`purge`")); + assert!(err(&mut s, json!({"with": "B"})).contains("`block`")); + // A block an xref brings in is not saved with the drawing. + s.doc_mut().unwrap().blocks.insert("X|A".into(), std::sync::Arc::new(Block::new("X|A"))); + assert!(err(&mut s, json!({"block": "X|A", "with": "B"})).contains("xref")); + assert!(err(&mut s, json!({"block": "A", "with": "X|A"})).contains("xref")); + assert_eq!(refs(s.doc().unwrap().model.iter()), ["N", "A"]); + // The Block Editor shows the block, not the drawing's references. + s.execute("bedit", &json!({"name": "B"})).unwrap(); + assert!(err(&mut s, json!({"block": "A", "with": "B"})).contains("Block Editor")); +} + +#[test] +fn blockreplace_asks_for_both_blocks_and_whether_to_purge() { + let (mut s, h) = drawing(); + s.cmdline("-blockreplace").unwrap(); + assert!(s.prompt_text().contains("Enter the name of the block to replace or [?/Select]"), "{}", s.prompt_text()); + s.cmdline("?").unwrap(); + assert!(s.log.iter().any(|l| l == "Blocks: A, B, N"), "{:?}", s.log); + s.cmdline("a").unwrap(); + assert!(s.prompt_text().contains("Enter the name of the replacement block for A or [?/Select]"), "{}", s.prompt_text()); + // A cannot replace itself: the prompt asks again. + s.cmdline("A").unwrap(); + assert!(s.prompt_text().contains("replacement block for A"), "{}", s.prompt_text()); + s.input(Input::Cancel).unwrap(); + assert!(s.running.is_none()); + assert_eq!(refs(s.doc().unwrap().model.iter()), ["N", "A"]); + + // The block to replace picked from one of its references; Enter purges it. + s.cmdline("blockreplace").unwrap(); + s.cmdline("Select").unwrap(); + s.input(Input::Pick(vec![h])).unwrap(); + assert!(s.prompt_text().contains("replacement block for A"), "{}", s.prompt_text()); + s.cmdline("B").unwrap(); + assert!(s.prompt_text().contains("Purge the unreferenced block definition A? [Yes/No] "), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert!(s.log.iter().any(|l| l == "3 references of block A replaced with B. Block A purged."), "{:?}", s.log); + let d = s.doc().unwrap(); + assert!(d.block("A").is_none()); + assert_eq!(refs(d.model.iter()), ["N", "B"]); + assert_eq!(insert(&s, h).0.block, "B"); +} + +#[test] +fn blockreplace_no_keeps_the_replaced_definition() { + let (mut s, _) = drawing(); + s.cmdline("blockreplace").unwrap(); + s.cmdline("A").unwrap(); + s.cmdline("B").unwrap(); + s.cmdline("No").unwrap(); + assert!(s.running.is_none()); + let d = s.doc().unwrap(); + assert!(d.block("A").is_some()); + assert_eq!(refs(d.model.iter()), ["N", "B"]); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 3edc0db95..7649e68fc 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -12,6 +12,7 @@ mod attrib_props; mod attribute_tools; mod block_keep; mod block_redefine; +mod blockreplace; mod break_closed_polyline; mod break_join_locked_layers; mod break_keeps_z; diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 58ce93144..7d1d9ec7c 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -786,6 +786,7 @@ Rename Text Style إعادة تسمية نمط النص Rename... إعادة تسمية... Repeat {command} تكرار {command} + Replace Block... استبدال الكتلة... Requires a point (x,y, @dx,dy or @d