From 5a82f090df367c4dd8bc6f5dfa4af3d144b5e0d7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 05:39:21 -0700 Subject: [PATCH] BLOCKREPLACE: Tools > Express Tools > Replace Block replaces every reference of one block with another BLOCKREPLACE (and -BLOCKREPLACE) turns every reference of one block, in model space, the layouts and other blocks, into a reference of another block at the same insertion point, scale and rotation. Each new reference carries the replacement's attributes, with the values of the tags both blocks define, and is annotative when the replacement is. The command then asks whether to purge the replaced definition, and removes it only when no dimension style names it and no block reference, dimension or multileader in the drawing or in another block definition does. The command refuses a replacement that references the block it replaces, a dynamic block as the block to replace, and blocks that come from an xref. It does not run while the Block Editor or REFEDIT is open. The ATTSYNC helpers in blocks/attribs.rs walk the references and build the new attributes. The self-reference check that BLOCK, INSERT of a file, REFCLOSE and Lisp block creation share now also follows dimension blocks and multileader block content, so BLOCK no longer accepts a redefinition of A from a multileader whose content is A. docs/parity-checklist.md is regenerated. --- crates/engine/src/cmd/blocks.rs | 15 +- crates/engine/src/cmd/blocks/attribs.rs | 30 ++- crates/engine/src/cmd/blocks/blockreplace.rs | 220 +++++++++++++++++++ crates/engine/src/cmd/blocks/purge.rs | 2 +- crates/engine/tests/it/block_redefine.rs | 6 + crates/engine/tests/it/blockreplace.rs | 186 ++++++++++++++++ crates/engine/tests/it/main.rs | 1 + crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pt_br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh.tsv | 1 + docs/parity-checklist.md | 13 +- 23 files changed, 463 insertions(+), 25 deletions(-) create mode 100644 crates/engine/src/cmd/blocks/blockreplace.rs create mode 100644 crates/engine/tests/it/blockreplace.rs 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 6400a1f38..e75ed6370 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -785,6 +785,7 @@ Rename Text Style إعادة تسمية نمط النص Rename... إعادة تسمية... Repeat {command} تكرار {command} + Replace Block... استبدال الكتلة... Requires a point (x,y, @dx,dy or @d **Generated:** 2026-10-15 by `cargo xtask parity`. Do not edit by hand. -> **Generated:** 2026-10-16 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-24 by `cargo xtask parity`. Do not edit by hand. Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment. -**Menu breadth: 283 / 491 items (58%)** · 427 registered commands. +**Menu breadth: 288 / 491 items (59%)** · 434 registered commands. | Menu | Items | Covered | % | |---|---|---|---| @@ -14,7 +13,7 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com | View | 75 | 31 | 41% | | Insert | 11 | 5 | 45% | | Format | 27 | 25 | 93% | -| Tools | 85 | 60 | 71% | +| Tools | 85 | 65 | 76% | | Draw | 87 | 55 | 63% | | Dimension | 28 | 28 | 100% | | Modify | 113 | 52 | 46% | @@ -101,14 +100,8 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - Tools > New UCS > Z - Tools > Named UCS... - Tools > Customize > Interface (CUI)... -- Tools > Customize > Edit Command Aliases (PGP)... - Tools > Import Settings - Tools > Export Settings -- Tools > Express Tools > Convert Text to Mtext -- Tools > Express Tools > Break-line Symbol -- Tools > Express Tools > Move/Copy/Rotate -- Tools > Express Tools > Explode Attributes to Text -- Tools > Express Tools > Replace Block... - Tools > Express Tools > Super Hatch > Image... - Tools > Express Tools > Super Hatch > Xref... - Tools > Express Tools > Super Hatch > Select Existing Object