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.
This commit is contained in:
dotCooCoo
2026-10-24 05:57:38 -07:00
parent b9f96d3dd3
commit 5a82f090df
23 changed files with 463 additions and 25 deletions
+12 -3
View File
@@ -74,6 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<String> {
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<String> = 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;
+19 -11
View File
@@ -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<Attrib> {
pub(super) fn synced(blk: &Block, defs: &[&Attrib], ins: &Insert) -> Vec<Attrib> {
let m = ins.transform(blk.base.xy());
defs.iter()
.map(|ad| {
@@ -119,12 +119,16 @@ fn references<'a>(ents: impl Iterator<Item = &'a Arc<Entity>>, 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<cadcraft_doc::Space> =
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))
@@ -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<String> {
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::<Vec<_>>(), &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<String> {
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<Value> {
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<Step> {
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<Step> {
match self.stage {
stage @ (Stage::Old | Stage::New) => match &i {
Input::Keyword(k) if k == "?" => {
let d = s.doc()?;
let mut names: Vec<String> = 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)
}
}
+1 -1
View File
@@ -119,7 +119,7 @@ fn entities(d: &Drawing) -> impl Iterator<Item = &std::sync::Arc<cadcraft_doc::E
}
/// The arrowhead block names a dimension's overrides set.
fn override_arrows(dm: &cadcraft_doc::Dimension) -> impl Iterator<Item = String> + '_ {
pub(in crate::cmd) fn override_arrows(dm: &cadcraft_doc::Dimension) -> impl Iterator<Item = String> + '_ {
dm.overrides
.iter()
.filter(|(k, _)| cadcraft_doc::DimStyle::field_name(k).is_some_and(|f| f.starts_with("arrowBlock") || f == "leaderArrowBlock"))
+6
View File
@@ -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]
+186
View File
@@ -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<String> = 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<Item = &'a std::sync::Arc<Entity>>) -> Vec<String> {
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] <Yes>"), "{}", 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"]);
}
+1
View File
@@ -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;
+1
View File
@@ -785,6 +785,7 @@
Rename Text Style إعادة تسمية نمط النص
Rename... إعادة تسمية...
Repeat {command} تكرار {command}
Replace Block... استبدال الكتلة...
Requires a point (x,y, @dx,dy or @d<a). يتطلب نقطة (x,y أو @dx,dy أو @d<a).
Reset Palettes إعادة تعيين اللوحات
Reset Panel Layout إعادة تعيين تخطيط اللوحات
1 # CADCraft Arabic interface catalog — original translations.
785
786
787
788
789
790
791
+1
View File
@@ -730,6 +730,7 @@
Rename Text Style Přejmenovat styl textu
Rename... Přejmenovat...
Repeat {command} Opakovat {command}
Replace Block... Nahradit blok...
Requires a point (x,y, @dx,dy or @d<a). Je vyžadován bod (x,y, @dx,dy nebo @d<a).
Reset Palettes Obnovit palety
Restore Obnovit
1 # CADCraft Czech interface catalog — original translations by @Losminek (AI-assisted).
730
731
732
733
734
735
736
+1
View File
@@ -622,6 +622,7 @@
Rename Text Style Textstil umbenennen
Rename... Umbenennen...
Repeat {command} {command} wiederholen
Replace Block... Block ersetzen...
Requires a point (x,y, @dx,dy or @d<a). Erfordert einen Punkt (x,y, @dx,dy oder @d<a).
Reset Palettes Paletten zurücksetzen
Restore Wiederherstellen
1 # CADCraft German interface catalog — original translations by the CADCraft contributors.
622
623
624
625
626
627
628
+1
View File
@@ -852,6 +852,7 @@
Rename Text Style Μετονομασία στυλ κειμένου
Rename... Μετονομασία...
Repeat {command} Επανάληψη εντολής {command}
Replace Block... Αντικατάσταση μπλοκ...
Requires a point (x,y, @dx,dy or @d<a). Απαιτείται σημείο (x,y, @dx,dy ή @d<a).
Reset keeps the interface language. Η επαναφορά διατηρεί τη γλώσσα του περιβάλλοντος.
Reset Palettes Επαναφορά παλετών
1 # Greek interface catalog for CADCraft. Original, AI-assisted translation by @Camthalion77 with ChatGPT.
852
853
854
855
856
857
858
+1
View File
@@ -828,6 +828,7 @@
Rename Text Style Renombrar estilo de texto
Rename... Renombrar...
Repeat {command} Repetir {command}
Replace Block... Reemplazar bloque...
Requires a point (x,y, @dx,dy or @d<a). Se requiere un punto (x,y, @dx,dy o @d<a).
Reset Palettes Restablecer paletas
Restore Restaurar
1 # CADCraft Spanish interface catalog — original translations.
828
829
830
831
832
833
834
+1
View File
@@ -616,6 +616,7 @@
Rename Text Style Renommer le style de texte
Rename... Renommer...
Repeat {command} Répéter {command}
Replace Block... Remplacer le bloc...
Requires a point (x,y, @dx,dy or @d<a). Nécessite un point (x,y, @dx,dy ou @d<a).
Reset Palettes Réinitialiser les palettes
Restore Restaurer
1 # CADCraft French interface catalog — original translations by @pechnikov2026-star.
616
617
618
619
620
621
622
+1
View File
@@ -861,6 +861,7 @@
Rename Text Style Szövegstílus átnevezése
Rename... Átnevezés...
Repeat {command} Ismétlés: {command}
Replace Block... Blokk cseréje...
Requires a point (x,y, @dx,dy or @d<a). Pontot kell megadni (x,y, @dx,dy vagy @d<a).
Reset Palettes Paletták visszaállítása
Reset Panel Layout Panelelrendezés visszaállítása
Can't render this file because it contains an unexpected character in line 16 and column 72.
+1
View File
@@ -742,6 +742,7 @@ menu Modify Edita
Rename Text Style Rinomina stile di testo
Rename... Rinomina...
Repeat {command} Ripeti {command}
Replace Block... Sostituisci blocco...
Requires a point (x,y, @dx,dy or @d<a). Richiede un punto (x,y, @dx,dy o @d<a).
Reset Palettes Reimposta tavolozze
Restore Ripristina
1 # CADCraft Italian interface catalog — original translations by Emanuele Bottelli (AI-assisted, Claude).
742
743
744
745
746
747
748
+1
View File
@@ -732,6 +732,7 @@
Rename Text Style 문자 스타일 이름 바꾸기
Rename... 이름 바꾸기...
Repeat {command} {command} 반복
Replace Block... 블록 바꾸기...
Requires a point (x,y, @dx,dy or @d<a). 점이 필요합니다(x,y, @dx,dy 또는 @d<a).
Reset Palettes 팔레트 초기화
Restore 복원
1 # CADCraft Korean interface catalog — original Korean translations.
732
733
734
735
736
737
738
+1
View File
@@ -820,6 +820,7 @@
Rename Text Style Renomear estilo de texto
Rename... Renomear...
Repeat {command} Repetir {command}
Replace Block... Substituir bloco...
Requires a point (x,y, @dx,dy or @d<a). Requer um ponto (x,y, @dx,dy ou @d<a).
Reset Palettes Redefinir paletas
Restore Restaurar
1 # CADCraft Brazilian Portuguese (pt-BR) interface catalog.
820
821
822
823
824
825
826
+1
View File
@@ -616,6 +616,7 @@
Rename Text Style Переименовать текстовый стиль
Rename... Переименовать...
Repeat {command} Повторить {command}
Replace Block... Заменить блок...
Requires a point (x,y, @dx,dy or @d<a). Требуется точка (x,y, @dx,dy или @d<a).
Reset Palettes Сбросить палитры
Restore Восстановить
1 # CADCraft Russian interface catalog — original translations by @pechnikov2026-star.
616
617
618
619
620
621
622
+1
View File
@@ -824,6 +824,7 @@
Rename Text Style Byt namn på textstil
Rename... Byt namn...
Repeat {command} Upprepa {command}
Replace Block... Ersätt block...
Requires a point (x,y, @dx,dy or @d<a). Kräver en punkt (x,y, @dx,dy eller @d<a).
Reset Palettes Återställ paletter
Restore Återställ
1 # CADCraft Swedish interface catalog — original translations by the CADCraft contributors.
824
825
826
827
828
829
830
+1
View File
@@ -862,6 +862,7 @@
Rename Text Style Metin Stilini Yeniden Adlandır
Rename... Yeniden Adlandır...
Repeat {command} Yinele: {command}
Replace Block... Bloğu Değiştir...
Requires a point (x,y, @dx,dy or @d<a). Bir nokta gerekir (x,y, @dx,dy veya @d<a).
Reset Palettes Paletleri Sıfırla
Reset Panel Layout Panel Düzenini Sıfırla
Can't render this file because it contains an unexpected character in line 11 and column 13.
+1
View File
@@ -748,6 +748,7 @@
Rename Text Style Перейменувати текстовий стиль
Rename... Перейменувати...
Repeat {command} Повторити {command}
Replace Block... Замінити блок...
Requires a point (x,y, @dx,dy or @d<a). Потрібна точка (x,y, @dx,dy або @d<a).
Reset Palettes Скинути палітри
Restore Відновити
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
748
749
750
751
752
753
754
+1
View File
@@ -617,6 +617,7 @@
Rename Text Style 重命名文字样式
Rename... 重命名...
Repeat {command} 重复 {command}
Replace Block... 替换块...
Requires a point (x,y, @dx,dy or @d<a). 需要一个点 (x,y, @dx,dy 或 @d<a)。
Reset Palettes 重置选项板
Restore 恢复
1 # CADCraft Simplified Chinese interface catalog — original translations.
617
618
619
620
621
622
623
+3 -10
View File
@@ -1,11 +1,10 @@
# Parity checklist
> **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