From 32be8a655ef27c88f1497153ae738c2ef07c5980 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 18:43:16 -0700 Subject: [PATCH 01/13] COMPAREEXPORT: a dimension block with another generated name does not make PART differ A named block such as PART was compared with the other drawing's by the blocks it draws, including a dimension's block. That block's generated name (`*D1` here, `*D3` there) was part of its signature, so the same PART counted as different and was copied as PART_2 with a copy of the dimension block. The comparison now leaves out an anonymous block's name and compares its contents. --- crates/engine/src/cmd/file/compare.rs | 12 +++++-- crates/engine/tests/compare_export.rs | 50 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/file/compare.rs b/crates/engine/src/cmd/file/compare.rs index bccd7b2d8..d5a9d3ebc 100644 --- a/crates/engine/src/cmd/file/compare.rs +++ b/crates/engine/src/cmd/file/compare.rs @@ -141,7 +141,8 @@ struct Signer<'a> { work: usize, /// When set, a block's signature also covers its base point, units, annotative and overlay /// flags and xref file, with a relative xref path taken from the folder given, and the - /// signatures of a multileader and a dimension cover their blocks'. + /// signatures of a multileader and a dimension cover their blocks'. An anonymous block's + /// signature then leaves out its name. frames: Option>, } @@ -190,9 +191,14 @@ impl<'a> Signer<'a> { return *v; } let mut h = std::collections::hash_map::DefaultHasher::new(); - key.hash(&mut h); + let block = self.d.block(name); + // With frames, an anonymous block (`*D3`, `*U7`) is signed by its contents only: the + // same block can have another generated name in another drawing. + if !(self.frames.is_some() && block.is_some_and(|b| b.anonymous)) { + key.hash(&mut h); + } if depth < MAX_BLOCK_DEPTH - && let Some(b) = self.d.block(name) + && let Some(b) = block { if let Some(folder) = self.frames { let mut xref = b.xref_path.clone(); diff --git a/crates/engine/tests/compare_export.rs b/crates/engine/tests/compare_export.rs index 8a2b0aa47..2aeab751b 100644 --- a/crates/engine/tests/compare_export.rs +++ b/crates/engine/tests/compare_export.rs @@ -352,6 +352,56 @@ fn a_named_block_whose_dimension_draws_another_block_shows_the_other_definition( assert_eq!(ends(&s, "*D2"), vec![5.0]); } +#[test] +fn a_named_block_whose_dimension_block_has_another_generated_name_keeps_this_drawings_definition() { + use cadcraft_engine::doc::{DimKind, Dimension}; + // PART holds the same dimension in both drawings; the block it draws holds the same line but + // is named `*D1` here and `*D3` in the other drawing. + let blocks = |drawn_name: &str, first: u64| { + let dim = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(15.0, 5.0, 0.0), + text_mid: Vec3::new(7.0, 5.0, 0.0), + p13: Vec3::ZERO, + p14: Vec3::new(30.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 30.0, + text_rotation: 0.0, + user_text_pos: false, + block: Some(drawn_name.into()), + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + }; + let mut part = Block::new("PART"); + part.entities.push(Entity::new(Handle(first), EntityKind::Dimension(dim))); + let mut drawn = Block::new(drawn_name); + drawn.entities.push(Entity::new(Handle(first + 1), line(1.0))); + [part, drawn] + }; + let mut s = Session::new(); + for b in blocks("*D1", 0x700) { + s.doc_mut().unwrap().blocks.insert(b.name.clone(), Arc::new(b)); + } + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap(); + let mut other = Drawing::new_imperial(); + other.handseed = 0x9000; + for b in blocks("*D3", 0x8000) { + other.blocks.insert(b.name.clone(), Arc::new(b)); + } + other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + let d = s.doc().unwrap(); + assert!(d.block("PART_2").is_none(), "PART draws the same thing in both drawings"); + assert!(d.block("*D3").is_none() && d.block("*D2").is_none(), "the other drawing's dimension block is not copied"); +} + #[test] fn block_contents_take_the_comparison_color() { let mut s = Session::new(); From 5685085add9a074a9fc18c6149544697ce4d39f9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 18:52:56 -0700 Subject: [PATCH 02/13] COMPAREEXPORT: a block whose objects are hidden or thicker in the other drawing is copied as PART_2 The comparison of a named block both drawings define counted each object's geometry, layer, color, linetype, lineweight and linetype scale. A block whose line was hidden, more transparent, thicker or extruded another way in the other drawing counted as the same, so the other drawing's references drew with this drawing's definition. These properties now count as well. --- crates/engine/src/cmd/file/compare.rs | 9 +++++++-- crates/engine/tests/compare_export.rs | 28 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/file/compare.rs b/crates/engine/src/cmd/file/compare.rs index d5a9d3ebc..58629f025 100644 --- a/crates/engine/src/cmd/file/compare.rs +++ b/crates/engine/src/cmd/file/compare.rs @@ -141,8 +141,9 @@ struct Signer<'a> { work: usize, /// When set, a block's signature also covers its base point, units, annotative and overlay /// flags and xref file, with a relative xref path taken from the folder given, and the - /// signatures of a multileader and a dimension cover their blocks'. An anonymous block's - /// signature then leaves out its name. + /// signatures of a multileader and a dimension cover their blocks'. Each object in a block + /// then also counts with its visibility, transparency, thickness and extrusion, and an + /// anonymous block's signature leaves out its name. frames: Option>, } @@ -214,6 +215,10 @@ impl<'a> Signer<'a> { self.work += 1; self.geometry(&e.kind, depth + 1).hash(&mut h); properties(e).hash(&mut h); + if self.frames.is_some() { + let c = &e.common; + format!("{:?}|{}|{}|{:?}", c.transparency, c.visible, (c.thickness * 1e6).round(), c.extrusion).hash(&mut h); + } } } let v = h.finish(); diff --git a/crates/engine/tests/compare_export.rs b/crates/engine/tests/compare_export.rs index 2aeab751b..a38d08482 100644 --- a/crates/engine/tests/compare_export.rs +++ b/crates/engine/tests/compare_export.rs @@ -402,6 +402,34 @@ fn a_named_block_whose_dimension_block_has_another_generated_name_keeps_this_dra assert!(d.block("*D3").is_none() && d.block("*D2").is_none(), "the other drawing's dimension block is not copied"); } +#[test] +fn a_named_block_whose_line_is_hidden_or_thicker_in_the_other_drawing_shows_the_other_definition() { + let run = |theirs: Common| { + let part = |common: Common, first: u64| { + let mut part = Block::new("PART"); + let mut e = Entity::new(Handle(first), line(1.0)); + e.common = common; + part.entities.push(e); + part + }; + let mut s = Session::new(); + s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part(Common::default(), 0x700))); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap(); + let mut other = Drawing::new_imperial(); + other.handseed = 0x9000; + other.blocks.insert("PART".into(), Arc::new(part(theirs, 0x8000))); + other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap(); + s.open_drawing(other, "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + s.doc().unwrap().block("PART_2").is_some() + }; + assert!(run(Common { visible: false, ..Common::default() }), "a hidden line"); + assert!(run(Common { thickness: 2.0, ..Common::default() }), "a line with thickness"); + assert!(!run(Common::default()), "the same line"); +} + #[test] fn block_contents_take_the_comparison_color() { let mut s = Session::new(); From 20087b4b5f78715e6860e7534d7bf75e7ec093d1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 19:16:04 -0700 Subject: [PATCH 03/13] COMPAREEXPORT: multiline attributes take the comparison color too A multiline attribute is drawn from its own multiline text, so an inline color code in it overrode the comparison color set on the attribute: an unchanged attribute could stay red, or a removed one green. The export now takes the inline colors out of the multiline text of a reference's attributes and of attribute definitions in the recolored blocks, as it does for MTEXT and multileader text. --- crates/engine/src/cmd/file/compare/export.rs | 16 +++++-- crates/engine/tests/compare_export.rs | 45 ++++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs index 9d24c1aef..078006781 100644 --- a/crates/engine/src/cmd/file/compare/export.rs +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -355,13 +355,21 @@ fn paint(d: &mut Drawing, e: &mut Entity, color: Color, tinted: &mut Tinted, dep }; for a in attribs.into_iter().flatten() { a.props.color = Some(color); + if let Some(ml) = a.multiline.as_mut() { + ml.mtext.contents = without_color_codes(&ml.mtext.contents); + } } - // Colors an object carries apart from its own: inline MTEXT colors, a dimension style's - // DIMCLRD, DIMCLRE and DIMCLRT, gradient colors, multiline element colors, the style colors of - // a table or a tolerance (through a recolored copy of the style) and inline cell colors. Hatch - // backgrounds, multiline fills and table cell fills are left out. + // Colors an object carries apart from its own: inline MTEXT colors (in multiline attributes + // too), a dimension style's DIMCLRD, DIMCLRE and DIMCLRT, gradient colors, multiline element + // colors, the style colors of a table or a tolerance (through a recolored copy of the style) + // and inline cell colors. Hatch backgrounds, multiline fills and table cell fills are left out. match &mut e.kind { EntityKind::MText(t) => t.contents = without_color_codes(&t.contents), + EntityKind::AttDef(a) => { + if let Some(ml) = a.multiline.as_mut() { + ml.mtext.contents = without_color_codes(&ml.mtext.contents); + } + } EntityKind::MLeader(m) => { if let Some(t) = m.text.as_mut() { t.contents = without_color_codes(&t.contents); diff --git a/crates/engine/tests/compare_export.rs b/crates/engine/tests/compare_export.rs index a38d08482..643c8b86b 100644 --- a/crates/engine/tests/compare_export.rs +++ b/crates/engine/tests/compare_export.rs @@ -518,6 +518,51 @@ fn colors_carried_apart_from_the_object_color_are_replaced() { assert_eq!(d.dim_style("Standard").unwrap().dim_line_color, Color::Index(1)); } +#[test] +fn inline_colors_of_multiline_attributes_are_replaced() { + use cadcraft_engine::doc::Attrib; + let attrib = |contents: &str| -> Attrib { + let at = serde_json::to_value(Vec3::ZERO).unwrap(); + serde_json::from_value(json!({ + "tag": "T", + "text": {"insert": at, "height": 1.0, "value": "x"}, + "multiline": {"mtext": {"insert": at, "height": 1.0, "contents": contents}, "readAs": "x"}, + })) + .unwrap() + }; + let mut s = Session::new(); + let mut part = Block::new("PART"); + part.entities.push(Entity::new(Handle(0x700), EntityKind::AttDef(attrib("\\C1;x")))); + s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part)); + let reference = Insert { attribs: vec![attrib("\\C3;x")], ..insert("PART") }; + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(reference)).unwrap(); + s.open_drawing(Drawing::new_imperial(), "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + let r = s.execute("compareexport", &json!({})).unwrap(); + let multiline = |a: &Attrib| a.multiline.as_ref().map(|m| m.mtext.contents.clone()); + let mut blocks = Vec::new(); + for e in members(&s, r["current"].as_str().unwrap()) { + if let EntityKind::Insert(i) = &e.kind { + assert_eq!(i.attribs.iter().map(multiline).collect::>(), [Some("x".to_string())], "the reference's attribute"); + blocks.push(i.block.clone()); + } + } + assert_eq!(blocks.len(), 1); + let d = s.doc().unwrap(); + let defs: Vec> = d + .block(&blocks[0]) + .unwrap() + .entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::AttDef(a) => Some(multiline(a)), + _ => None, + }) + .collect(); + assert_eq!(defs, [Some("x".to_string())], "the attribute definition in the recolored block"); +} + #[test] fn edits_after_compare_count_and_objects_keep_their_layers() { let mut s = Session::new(); From 4dbcbfdaaa070e7f904f6113f2b2e8c52dc9b4c4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 19:37:44 -0700 Subject: [PATCH 04/13] COMPAREEXPORT: dimension block copies stay *D blocks, and image paths count in block reuse A dimension's block took a recolored copy named COMPARE__D1, and the DXF writer redraws a dimension whose block is not a *D block, so saving the snapshot replaced geometry read from a file. The copy now gets a free *D name. A named block both drawings define was reused when its images and data links had the same relative paths, even when they were relative to different folders. These paths are now taken from each drawing's folder before the blocks are compared. --- crates/engine/src/cmd/file/compare.rs | 13 ++- crates/engine/src/cmd/file/compare/export.rs | 18 +++- crates/engine/tests/compare_export.rs | 97 +++++++++++++++++++- 3 files changed, 118 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/file/compare.rs b/crates/engine/src/cmd/file/compare.rs index 58629f025..d4e2878e8 100644 --- a/crates/engine/src/cmd/file/compare.rs +++ b/crates/engine/src/cmd/file/compare.rs @@ -140,7 +140,8 @@ struct Signer<'a> { blocks: HashMap, work: usize, /// When set, a block's signature also covers its base point, units, annotative and overlay - /// flags and xref file, with a relative xref path taken from the folder given, and the + /// flags and xref file, with relative xref, image and data link paths taken from the folder + /// given, and the /// signatures of a multileader and a dimension cover their blocks'. Each object in a block /// then also counts with its visibility, transparency, thickness and extrusion, and an /// anonymous block's signature leaves out its name. @@ -213,7 +214,15 @@ impl<'a> Signer<'a> { break; } self.work += 1; - self.geometry(&e.kind, depth + 1).hash(&mut h); + match self.frames { + // An image's file and a table's data link, from the folder given. + Some(folder) if matches!(e.kind, EntityKind::Image(_) | EntityKind::Table(_)) => { + let mut k = e.kind.clone(); + export::rebase(&mut k, folder); + self.geometry(&k, depth + 1).hash(&mut h); + } + _ => self.geometry(&e.kind, depth + 1).hash(&mut h), + } properties(e).hash(&mut h); if self.frames.is_some() { let c = &e.common; diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs index 078006781..88a1c4350 100644 --- a/crates/engine/src/cmd/file/compare/export.rs +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -5,8 +5,9 @@ //! only in this drawing or changed in it are drawn in the overlay's green, objects only in the //! other drawing or changed there in its red, and objects in both in gray (color 8). A block //! reference, dimension or multileader refers to a copy of its block whose objects all have that -//! color (`COMPARE_CURRENT_`, `COMPARE_COMPARED_`, `COMPARE_COMMON_`), and a -//! table or tolerance uses a recolored copy of its style; xrefs keep their own colors. The new +//! color (`COMPARE_CURRENT_`, `COMPARE_COMPARED_`, `COMPARE_COMMON_`; for a +//! dimension's `*D` block, another `*D` block), and a table or tolerance uses a recolored copy +//! of its style; xrefs keep their own colors. The new //! drawing has this drawing's layers, styles and blocks, plus the layers and styles of the other //! drawing that this one lacks and the blocks its copied objects refer to; an anonymous block of //! the other drawing whose name this one uses is copied under a free name. Relative xref, image @@ -130,7 +131,7 @@ pub(super) fn rebase_path(p: &mut String, folder: Option<&Path>) { } /// Rebase the file an object refers to: an image's file, a table's data link. -fn rebase(k: &mut EntityKind, folder: Option<&Path>) { +pub(super) fn rebase(k: &mut EntityKind, folder: Option<&Path>) { match k { EntityKind::Image(i) => rebase_path(&mut i.path, folder), EntityKind::Table(t) => { @@ -518,11 +519,18 @@ fn tinted_block(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted, return None; } tinted.copied += b.entities.len(); - let new = free_name(&format!("COMPARE_{}_{}", tag(color), b.name.trim_start_matches('*')), d.blocks.len(), |n| d.block(n).is_some()); + // A dimension's block copy stays a `*D` block, so saving keeps its geometry instead of + // drawing the dimension again. + let dim_block = b.anonymous && b.name.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("*D")); + let new = if dim_block { + (1..=d.blocks.len() + 1).map(|i| format!("*D{i}")).find(|n| d.block(n).is_none()).unwrap_or_else(|| format!("*D{}", d.blocks.len() + 2)) + } else { + free_name(&format!("COMPARE_{}_{}", tag(color), b.name.trim_start_matches('*')), d.blocks.len(), |n| d.block(n).is_some()) + }; tinted.names.insert(key, new.clone()); // Hold the name while the nested copies pick theirs. d.blocks.insert(new.clone(), Arc::new(Block::new(&new))); - let mut copy = Block { name: new.clone(), anonymous: false, entities: EntityStore::new(), ..(*b).clone() }; + let mut copy = Block { name: new.clone(), anonymous: dim_block, entities: EntityStore::new(), ..(*b).clone() }; for e in b.entities.iter() { let h = d.new_handle(); let mut e = Entity { handle: h, ..(**e).clone() }; diff --git a/crates/engine/tests/compare_export.rs b/crates/engine/tests/compare_export.rs index 643c8b86b..c001f6bd7 100644 --- a/crates/engine/tests/compare_export.rs +++ b/crates/engine/tests/compare_export.rs @@ -397,9 +397,7 @@ fn a_named_block_whose_dimension_block_has_another_generated_name_keeps_this_dra s.execute("document.switch", &json!({"index": 0})).unwrap(); s.execute("compare", &json!({"document": "Other"})).unwrap(); s.execute("compareexport", &json!({})).unwrap(); - let d = s.doc().unwrap(); - assert!(d.block("PART_2").is_none(), "PART draws the same thing in both drawings"); - assert!(d.block("*D3").is_none() && d.block("*D2").is_none(), "the other drawing's dimension block is not copied"); + assert!(s.doc().unwrap().block("PART_2").is_none(), "PART draws the same thing in both drawings"); } #[test] @@ -430,6 +428,99 @@ fn a_named_block_whose_line_is_hidden_or_thicker_in_the_other_drawing_shows_the_ assert!(!run(Common::default()), "the same line"); } +#[test] +fn a_named_block_with_the_same_relative_image_path_from_another_folder_shows_the_other_definition() { + // PART holds an image at `a.png` in both drawings; this drawing is unsaved, the other one is + // a file in another folder, so its `a.png` is another file. + let part = |first: u64| { + let at = serde_json::to_value(Vec3::ZERO).unwrap(); + let image = serde_json::from_value(json!({"insert": at, "u": at, "v": at, "size": [1.0, 1.0], "path": "a.png"})).unwrap(); + let mut part = Block::new("PART"); + part.entities.push(Entity::new(Handle(first), EntityKind::Image(image))); + part + }; + let dir = std::env::temp_dir().join("compareexport-image-folder"); + let mut s = Session::new(); + s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part(0x700))); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap(); + let mut other = Drawing::new_imperial(); + other.handseed = 0x9000; + other.blocks.insert("PART".into(), Arc::new(part(0x8000))); + other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("PART") })).unwrap(); + s.open_drawing(other, "Other", Some(dir.join("other.dxf").to_string_lossy().to_string())); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + let d = s.doc().unwrap(); + let paths: Vec = d + .block("PART_2") + .map(|b| { + b.entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Image(i) => Some(i.path.clone()), + _ => None, + }) + .collect() + }) + .unwrap_or_default(); + assert_eq!(paths.len(), 1, "the other drawing's PART is copied"); + assert_eq!(std::path::Path::new(&paths[0]).parent(), Some(std::path::absolute(&dir).unwrap().as_path())); +} + +#[test] +fn a_dimension_block_copy_keeps_its_geometry_when_the_snapshot_is_saved() { + use cadcraft_engine::doc::{DimKind, Dimension}; + // The dimension's block holds a line its parameters would not draw: a block read from a file. + let dim = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(15.0, 5.0, 0.0), + text_mid: Vec3::new(7.0, 5.0, 0.0), + p13: Vec3::ZERO, + p14: Vec3::new(30.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 30.0, + text_rotation: 0.0, + user_text_pos: false, + block: Some("*D1".into()), + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + }; + let mut s = Session::new(); + let mut drawn = Block::new("*D1"); + drawn.entities.push(Entity::new(Handle(0x701), line(123.0))); + s.doc_mut().unwrap().blocks.insert("*D1".into(), Arc::new(drawn)); + s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Dimension(dim)).unwrap(); + s.open_drawing(Drawing::new_imperial(), "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + let read = cadcraft_io::read_dxf(cadcraft_io::write_dxf(s.doc().unwrap()).as_bytes()).unwrap(); + let block = read + .model + .iter() + .find_map(|e| match &e.kind { + EntityKind::Dimension(dm) => dm.block.clone(), + _ => None, + }) + .unwrap(); + let ends: Vec = read + .block(&block) + .unwrap() + .entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Line(l) => Some(l.b.x), + _ => None, + }) + .collect(); + assert_eq!(ends, [123.0], "the saved dimension block is the copy's"); +} + #[test] fn block_contents_take_the_comparison_color() { let mut s = Session::new(); From 53049d82e0fe0dd5de1a782d13a53a24fea95bcc Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 03:58:15 +0000 Subject: [PATCH 05/13] COMPAREEXPORT: keep #2049's *D copies of dimension blocks --- crates/engine/src/cmd/file/compare/export.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs index 7aab32b0d..4e6d28401 100644 --- a/crates/engine/src/cmd/file/compare/export.rs +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -380,9 +380,6 @@ fn paint(d: &mut Drawing, e: &mut Entity, color: Color, tinted: &mut Tinted, dep } } EntityKind::Dimension(dm) => { - // Drawn again from its geometry in the object's color; a named copy of its `*D` - // block would stay behind in saved files. - dm.block = None; if let Ok(by_block) = serde_json::to_value(Color::ByBlock) { for var in ["DIMCLRD", "DIMCLRE", "DIMCLRT"] { dm.overrides.retain(|k, _| DimStyle::field_name(k) != DimStyle::field_name(var)); From 642c3a59a33ea549413ecb560a09e73987fbed1f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 04:04:14 +0000 Subject: [PATCH 06/13] COMPAREEXPORT: free block and style names come from a next-index counter per name base instead of a scan from 1 --- crates/engine/src/cmd/file/compare/export.rs | 55 ++++++++++++++++---- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs index 4e6d28401..5988c51d9 100644 --- a/crates/engine/src/cmd/file/compare/export.rs +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -327,6 +327,34 @@ struct Tinted { uncolored: bool, /// Objects copied into block definitions so far, counted against [`MAX_COPIED`]. copied: usize, + next: NextFree, + /// The drawing's block names in upper case, filled on the first block copy. Only + /// [`tinted_block`] adds blocks after that, and it adds their names here too. + blocks: Option>, +} + +/// The next index to try for each name base. Names are only added while a snapshot is built, +/// so an index found taken stays taken, and each search starts where the last one for the same +/// base stopped: a name costs O(1) amortised instead of a scan from 1. +#[derive(Default)] +struct NextFree(HashMap<(&'static str, String), usize>); + +impl NextFree { + /// The first of `name(i)` for `i` from 1 that `taken` doesn't hold, skipping the indices + /// earlier searches for `base` in namespace `kind` passed. `n` is how many names there are, + /// so one of `name(1)`…`name(n + 1)` is free. The caller adds the returned name. + fn find(&mut self, kind: &'static str, base: &str, n: usize, name: impl Fn(usize) -> String, taken: impl Fn(&str) -> bool) -> String { + let key = (kind, base.to_ascii_uppercase()); + let start = self.0.get(&key).copied().unwrap_or(1); + let i = (start..=n.saturating_add(1)).find(|&i| !taken(&name(i))).unwrap_or_else(|| n.saturating_add(2).max(start)); + self.0.insert(key, i.saturating_add(1)); + name(i) + } +} + +/// `base` for 1, `base_2`, `base_3`… after it. +fn suffixed(base: &str, i: usize) -> String { + if i == 1 { base.to_string() } else { format!("{base}_{i}") } } /// The word a comparison color adds to the names of its recolored copies. @@ -342,10 +370,7 @@ fn tag(color: Color) -> &'static str { /// `base`, or `base_2`, `base_3`… when `taken` holds that name. `n` is how many names there are. fn free_name(base: &str, n: usize, taken: impl Fn(&str) -> bool) -> String { - (1..=n + 1) - .map(|i| if i == 1 { base.to_string() } else { format!("{base}_{i}") }) - .find(|c| !taken(c)) - .unwrap_or_else(|| format!("{base}_{}", n + 2)) + (1..=n + 1).map(|i| suffixed(base, i)).find(|c| !taken(c)).unwrap_or_else(|| suffixed(base, n + 2)) } /// Give an object its comparison color: its own color, its attributes' colors, and, through @@ -429,7 +454,8 @@ fn tinted_dim_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tint return Some(n.clone()); } let st = d.dim_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone(); - let new = free_name(&format!("COMPARE_{}_{}", tag(color), st.name), d.dim_styles.len(), |n| { + let base = format!("COMPARE_{}_{}", tag(color), st.name); + let new = tinted.next.find("dimension style", &base, d.dim_styles.len(), |i| suffixed(&base, i), |n| { d.dim_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)) }); d.dim_styles.push(DimStyle { @@ -451,7 +477,8 @@ fn tinted_table_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Ti return Some(n.clone()); } let st = d.table_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone(); - let new = free_name(&format!("COMPARE_{}_{}", tag(color), st.name), d.table_styles.len(), |n| { + let base = format!("COMPARE_{}_{}", tag(color), st.name); + let new = tinted.next.find("table style", &base, d.table_styles.len(), |i| suffixed(&base, i), |n| { d.table_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)) }); let names = @@ -525,11 +552,16 @@ fn tinted_block(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tinted, // A dimension's block copy stays a `*D` block, so saving keeps its geometry instead of // drawing the dimension again. let dim_block = b.anonymous && b.name.get(..2).is_some_and(|p| p.eq_ignore_ascii_case("*D")); + let Tinted { next, blocks, .. } = tinted; + let taken = blocks.get_or_insert_with(|| d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect()); + let is_taken = |n: &str| taken.contains(&n.to_ascii_uppercase()); let new = if dim_block { - (1..=d.blocks.len() + 1).map(|i| format!("*D{i}")).find(|n| d.block(n).is_none()).unwrap_or_else(|| format!("*D{}", d.blocks.len() + 2)) + next.find("block", "*D", d.blocks.len(), |i| format!("*D{i}"), is_taken) } else { - free_name(&format!("COMPARE_{}_{}", tag(color), b.name.trim_start_matches('*')), d.blocks.len(), |n| d.block(n).is_some()) + let base = format!("COMPARE_{}_{}", tag(color), b.name.trim_start_matches('*')); + next.find("block", &base, d.blocks.len(), |i| suffixed(&base, i), is_taken) }; + taken.insert(new.to_ascii_uppercase()); tinted.names.insert(key, new.clone()); // Hold the name while the nested copies pick theirs. d.blocks.insert(new.clone(), Arc::new(Block::new(&new))); @@ -605,18 +637,19 @@ fn copy_blocks<'a>( // Names `d` does not use stay; the others get free names, avoiding the kept ones. let mut renames = Renames::new(); let mut taken: HashSet = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect(); + let mut next = NextFree::default(); for b in needed.iter().filter(|b| d.block(&b.name).is_none()) { taken.insert(b.name.to_ascii_uppercase()); renames.insert(b.name.to_ascii_uppercase(), b.name.clone()); } for b in needed.iter().filter(|b| d.block(&b.name).is_some()) { + let is_taken = |n: &str| taken.contains(&n.to_ascii_uppercase()); let free = if b.anonymous { let prefix: String = b.name.chars().take_while(|c| *c == '*' || c.is_ascii_alphabetic()).collect(); - (1..=taken.len() + 1).map(|i| format!("{prefix}{i}")).find(|n| !taken.contains(&n.to_ascii_uppercase())) + next.find("anonymous block", &prefix, taken.len(), |i| format!("{prefix}{i}"), is_taken) } else { - Some(free_name(&b.name, taken.len(), |n| taken.contains(&n.to_ascii_uppercase()))) + next.find("block", &b.name, taken.len(), |i| suffixed(&b.name, i), is_taken) }; - let Some(free) = free else { continue }; taken.insert(free.to_ascii_uppercase()); renames.insert(b.name.to_ascii_uppercase(), free); } From c42763ba0f50c552bb1e31cab8d4ce0a47ca480b Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 04:12:57 +0000 Subject: [PATCH 07/13] CUTCLIP and CUTBASE leave the clipboard alone when every object is on a locked layer, and say how many were locked --- crates/engine/src/cmd/edit.rs | 44 +++++++++++++++++++++++++------- crates/engine/tests/clipboard.rs | 31 +++++++++++++++++++++- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f6cb4412d..dab5d4eb7 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -463,29 +463,53 @@ fn run_copybase(s: &mut Session, p: &Value) -> Result { Ok(json!({ "copied": n })) } -/// Copy the objects that are not on locked layers to the clipboard and erase them. -fn cut_to_clip(s: &mut Session, hs: &[Handle], base: Option) -> Result { - let hs: Vec = hs.iter().copied().filter(|h| !super::curves::is_locked(s, *h)).collect(); +/// Copy the objects that are not on locked layers to the clipboard and erase them. Returns how +/// many were cut and the message to show: how many were on a locked layer, and that nothing was +/// cut when that is all of them (the clipboard then keeps what it held). +fn cut_to_clip(s: &mut Session, hs: &[Handle], base: Option) -> Result<(usize, Option)> { + let mut seen = std::collections::HashSet::new(); + let live: Vec = { + let d = s.doc()?; + hs.iter().copied().filter(|h| seen.insert(*h) && d.entity(*h).is_some()).collect() + }; + let (hs, locked): (Vec, Vec) = live.into_iter().partition(|h| !super::curves::is_locked(s, *h)); + let note = match locked.len() { + 0 => None, + 1 => Some("1 object was on a locked layer.".to_string()), + n => Some(format!("{n} objects were on a locked layer.")), + }; + if hs.is_empty() { + let msg = note.map_or_else(|| "Nothing was cut.".to_string(), |m| format!("{m} Nothing was cut.")); + return Ok((0, Some(msg))); + } let n = copy_to_clip(s, &hs, base)?; let d = s.doc_mut()?; for h in &hs { d.remove_entity(*h); } s.set_selection(Vec::new()); - Ok(n) + Ok((n, note)) +} + +fn cut_result(n: usize, msg: Option) -> Value { + let mut r = json!({ "cut": n }); + if let Some(m) = msg { + r["message"] = json!(m); + } + r } fn run_cut(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; - let n = cut_to_clip(s, &hs, None)?; - Ok(json!({ "cut": n })) + let (n, msg) = cut_to_clip(s, &hs, None)?; + Ok(cut_result(n, msg)) } fn run_cutbase(s: &mut Session, p: &Value) -> Result { let base = point_req("cutbase", p, "base")?; let hs = targets(s, p)?; - let n = cut_to_clip(s, &hs, Some(base))?; - Ok(json!({ "cut": n })) + let (n, msg) = cut_to_clip(s, &hs, Some(base))?; + Ok(cut_result(n, msg)) } /// Paste the clipboard moved by `offset` (the clipboard brings the layers, styles and blocks its @@ -680,7 +704,9 @@ struct CopyBaseM { impl CopyBaseM { fn copy(&self, s: &mut Session, hs: &[Handle]) -> Result { if self.cut { - cut_to_clip(s, hs, self.base)?; + if let (_, Some(m)) = cut_to_clip(s, hs, self.base)? { + s.echo(m); + } } else { copy_to_clip(s, hs, self.base)?; } diff --git a/crates/engine/tests/clipboard.rs b/crates/engine/tests/clipboard.rs index c410db91c..650d63092 100644 --- a/crates/engine/tests/clipboard.rs +++ b/crates/engine/tests/clipboard.rs @@ -182,7 +182,7 @@ fn cutbase_cuts_with_the_given_base_point() { s.active = a; let r = s.execute("cutbase", &json!({ "handles": handles(&s), "base": [5, 5] })).unwrap(); // The wall is on a locked layer, so only the door is cut. - assert_eq!(r, json!({ "cut": 1 })); + assert_eq!(r, json!({ "cut": 1, "message": "1 object was on a locked layer." })); assert_eq!(s.clipboard.len(), 1); assert_eq!(s.clipboard_base, Vec2::new(5.0, 5.0)); let left = model(&s); @@ -201,6 +201,35 @@ fn cutbase_cuts_with_the_given_base_point() { assert_eq!(s.doc().unwrap().model.len(), 2); } +#[test] +fn cutting_only_locked_objects_keeps_the_clipboard() { + let mut s = Session::empty(); + let mut da = drawing_a(); + for l in &mut da.layers { + l.locked = true; + } + s.open_drawing(da, "A", None); + let hs = handles(&s); + s.execute("copyclip", &json!({ "handles": [hs[0]] })).unwrap(); + let held = s.clipboard.clone(); + let r = s.execute("cutclip", &json!({ "handles": hs })).unwrap(); + assert_eq!(r, json!({ "cut": 0, "message": "2 objects were on a locked layer. Nothing was cut." })); + assert_eq!(model(&s).len(), 2, "nothing was erased"); + assert_eq!(s.clipboard.len(), 1, "the clipboard keeps what it held"); + assert_eq!(s.clipboard[0].handle, held[0].handle); + + // CUTBASE at the command line says so too. + s.start("cutbase").unwrap(); + s.input(cadcraft_engine::Input::Point(Vec2::new(3.0, 4.0))).unwrap(); + let wall = s.doc().unwrap().model.iter().next().unwrap().handle; + s.input(cadcraft_engine::Input::Pick(vec![wall])).unwrap(); + s.input(cadcraft_engine::Input::Enter).unwrap(); + assert!(s.running.is_none()); + assert!(s.log.iter().any(|l| l.contains("1 object was on a locked layer. Nothing was cut.")), "{:?}", s.log); + assert_eq!(s.clipboard.len(), 1); + assert_eq!(model(&s).len(), 2); +} + #[test] fn cutbase_and_copybase_need_a_base_point_and_take_each_object_once() { let mut s = Session::empty(); From b90d68ec343c8f351852aa95d2aebfd3965e01be Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 04:17:52 +0000 Subject: [PATCH 08/13] BLOCKREPLACE: a dynamic block or a replacement that references the block is refused when its name is given, and the prompt asks again --- crates/engine/src/cmd/blocks/blockreplace.rs | 32 +++++++++++++----- crates/engine/tests/it/blockreplace.rs | 35 +++++++++++++++++++- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/blocks/blockreplace.rs b/crates/engine/src/cmd/blocks/blockreplace.rs index c20012784..03bac655d 100644 --- a/crates/engine/src/cmd/blocks/blockreplace.rs +++ b/crates/engine/src/cmd/blocks/blockreplace.rs @@ -27,6 +27,20 @@ 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()) } +/// Why the references of block `old` cannot be replaced: a dynamic block's references keep their +/// property values in data that names the block. +fn not_replaceable(d: &Drawing, old: &str) -> Option { + cadcraft_doc::dynblock::dynamic_block(d, old).map(|_| format!("Block \"{old}\" is a dynamic block; its references cannot be replaced.")) +} + +/// Why block `new` cannot replace block `old`: references of `old` inside `new` would make `new` +/// reference itself. +fn cannot_replace_with(d: &Drawing, old: &str, new: &str) -> Option { + let blk = d.block(new)?; + references_block(d, &blk.entities.iter().map(|e| (**e).clone()).collect::>(), old) + .then(|| format!("Block \"{new}\" references \"{old}\", so it cannot replace it.")) +} + /// 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)> { @@ -46,15 +60,10 @@ fn replace(s: &mut Session, old: &str, new: &str, purge: bool) -> Result<(usize, 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."))); + if let Some(why) = not_replaceable(d, &old).or_else(|| cannot_replace_with(d, &old, &new)) { + return Err(bad(cmd, why)); } 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()?; @@ -132,17 +141,24 @@ struct BlockreplaceM { } impl BlockreplaceM { - /// Take `name` as the answer to the current name prompt. + /// Take `name` as the answer to the current name prompt. A block that cannot take part is + /// refused here, so the prompt asks again. 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 { + if let Some(why) = not_replaceable(d, &name) { + return Err(EngineError::Other(why)); + } 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())); } + if let Some(why) = cannot_replace_with(d, &self.old, &name) { + return Err(EngineError::Other(why)); + } self.new = name; self.stage = Stage::Purge; } diff --git a/crates/engine/tests/it/blockreplace.rs b/crates/engine/tests/it/blockreplace.rs index eefb23f1f..2cd18c81c 100644 --- a/crates/engine/tests/it/blockreplace.rs +++ b/crates/engine/tests/it/blockreplace.rs @@ -2,7 +2,8 @@ //! 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::doc::foreign::{Foreign, ForeignExtra, ForeignObject}; +use cadcraft_engine::doc::{Block, Common, Entity, EntityKind, Handle, Insert, RawTag, Space}; use cadcraft_engine::{Input, Session}; use serde_json::{Value, json}; @@ -184,3 +185,35 @@ fn blockreplace_no_keeps_the_replaced_definition() { assert!(d.block("A").is_some()); assert_eq!(refs(d.model.iter()), ["N", "B"]); } + +#[test] +fn blockreplace_refuses_a_block_as_soon_as_its_name_is_given() { + let (mut s, _) = drawing(); + s.cmdline("blockreplace").unwrap(); + s.cmdline("A").unwrap(); + // N holds a reference of A: refused at once, and the prompt asks for another name. + s.cmdline("N").unwrap(); + assert!(s.log.iter().any(|l| l.contains("Block \"N\" references \"A\"")), "{:?}", s.log); + 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?"), "{}", s.prompt_text()); + s.input(Input::Cancel).unwrap(); + + // A dynamic block is refused as the block to replace. + let mut d = s.doc().unwrap().clone(); + let mut f = Foreign::default(); + f.records.insert(("BLOCK_RECORD".into(), "A".into()), ForeignExtra { xdict: Some(Handle(0xF00)), ..Default::default() }); + let tags = |t: &[(i32, &str)]| t.iter().map(|(c, v)| RawTag { code: *c, value: (*v).into() }).collect(); + f.objects.push(ForeignObject { handle: Handle(0xF00), kind: "DICTIONARY".into(), tags: tags(&[(100, "AcDbDictionary"), (3, "ACAD_ENHANCEDBLOCK"), (360, "F01")]) }); + f.objects.push(ForeignObject { handle: Handle(0xF01), kind: "ACAD_EVALUATION_GRAPH".into(), tags: tags(&[(100, "AcDbEvalGraph")]) }); + d.foreign = std::sync::Arc::new(f); + s.open_drawing(d, "dynamic", None); + let e = s.execute("blockreplace", &json!({"block": "A", "with": "B"})).unwrap_err().to_string(); + assert!(e.contains("dynamic block"), "{e}"); + s.cmdline("blockreplace").unwrap(); + s.cmdline("A").unwrap(); + assert!(s.log.iter().any(|l| l.contains("Block \"A\" is a dynamic block")), "{:?}", s.log); + assert!(s.prompt_text().contains("Enter the name of the block to replace"), "{}", s.prompt_text()); + s.input(Input::Cancel).unwrap(); + assert_eq!(refs(s.doc().unwrap().model.iter()), ["N", "A"]); +} From 8296bbc6428ff2a542ebc2dbc2d0ade8265707ca Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 04:22:57 +0000 Subject: [PATCH 09/13] THICKNESS: a typed value out of range is reported as such and the prompt asks again --- crates/engine/src/cmd/props.rs | 24 +++++++++++++++++++----- crates/engine/src/tests.rs | 6 ++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 75dc0b046..c89204c22 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -539,10 +539,17 @@ impl Interactive for LtscaleM { } } +/// The largest THICKNESS, either way. +const MAX_THICKNESS: f64 = 1e12; + +fn thickness_in_range(t: f64) -> bool { + t.is_finite() && t.abs() <= MAX_THICKNESS +} + fn run_thickness(s: &mut Session, p: &Value) -> Result { let t = f64_req("thickness", p, "thickness")?; - if !t.is_finite() || t.abs() > 1e12 { - return Err(bad("thickness", "thickness is out of range")); + if !thickness_in_range(t) { + return Err(bad("thickness", "thickness is out of range (-1e12 to 1e12)")); } s.doc_mut()?.header.set_f64("THICKNESS", t); Ok(json!({ "thickness": t })) @@ -565,9 +572,16 @@ impl Interactive for ThicknessM { Input::Text(t) => t, _ => return Ok(Step::Continue), }; - match super::machines::number(&text).map(|t| run_thickness(s, &json!({ "thickness": t }))) { - Some(Ok(_)) => Ok(Step::Done), - _ => { + match super::machines::number(&text) { + Some(t) if thickness_in_range(t) => { + run_thickness(s, &json!({ "thickness": t }))?; + Ok(Step::Done) + } + Some(_) => { + s.echo("Requires a value between -1e12 and 1e12."); + Ok(Step::Continue) + } + None => { s.echo("Requires a number."); Ok(Step::Continue) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 731256107..8c590dd97 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1317,6 +1317,12 @@ fn format_thickness_asks_for_the_thickness() { // After text that is not a number, the prompt asks again. s.cmdline("thick").unwrap(); assert!(s.current_prompt().is_some(), "asked again"); + assert_eq!(s.log.last().map(String::as_str), Some("Requires a number.")); + // A value out of range says so, and the prompt asks again. + s.cmdline("2e12").unwrap(); + assert!(s.current_prompt().is_some(), "asked again"); + assert_eq!(s.log.last().map(String::as_str), Some("Requires a value between -1e12 and 1e12.")); + assert_eq!(s.doc().unwrap().header.f64("THICKNESS", 0.0), 0.0); // A negative thickness is accepted. s.cmdline("-1.5").unwrap(); assert!(s.current_prompt().is_none()); From 23d44cd9c88fc82fc54ba1a5e4ad0193b63fa5f1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 04:26:14 +0000 Subject: [PATCH 10/13] MEASUREGEOM Volume: the height can be given by two points, and Enter at the height prompt says a height is required --- crates/engine/src/cmd/inquiry.rs | 43 ++++++++++++++++------ crates/engine/tests/it/inquiry_keywords.rs | 8 ++++ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index 26f074a96..9962c3fd6 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -933,6 +933,8 @@ pub(super) struct AreaM { volume: bool, /// Areas measured in volume mode that still wait for their height, oldest first. pending: Vec, + /// The first point of a height given by two points. + height_from: Option, } impl AreaM { @@ -960,6 +962,19 @@ impl AreaM { AreaMode::Subtract => self.total - v, } } + /// Measure the oldest pending area's volume with height `h`. + fn height(&mut self, s: &mut Session, h: f64) -> Step { + match self.pending.first().map(|a| a * h.abs()) { + Some(v) if v.is_finite() && self.total_with(v).is_finite() => { + self.pending.remove(0); + self.volume_measured(s, v) + } + _ => { + s.echo("The volume is too large."); + Step::Continue + } + } + } /// Report a volume (and the running total in add/subtract mode); ends the command in single mode. fn volume_measured(&mut self, s: &mut Session, v: f64) -> Step { s.echo(volume_text(s, v)); @@ -1003,7 +1018,10 @@ impl Interactive for AreaM { } fn prompt(&self, _s: &Session) -> Prompt { if !self.pending.is_empty() { - return Prompt::new(format!("{}Specify height", self.mode_prefix()), Accept::NUMBER); + return match self.height_from { + Some(p) => Prompt::new("Specify second point", Accept::POINT).base(p), + None => Prompt::new(format!("{}Specify height", self.mode_prefix()), Accept::POINT_OR_NUMBER), + }; } if self.object { return Prompt::new(format!("{}Select objects", self.mode_prefix()), Accept::SELECT); @@ -1023,17 +1041,20 @@ impl Interactive for AreaM { } fn input(&mut self, s: &mut Session, i: Input) -> Result { if !self.pending.is_empty() { - if let Input::Text(t) = i { - match crate::units::parse_distance(&t).filter(|v| v.is_finite()) { - Some(h) => match self.pending.first().map(|a| a * h.abs()) { - Some(v) if v.is_finite() && self.total_with(v).is_finite() => { - self.pending.remove(0); - return Ok(self.volume_measured(s, v)); - } - _ => s.echo("The volume is too large."), - }, - None => s.echo("Requires a distance."), + // A height is typed, or given by two points (their distance). + match (i, self.height_from) { + (Input::Point(p), None) => self.height_from = Some(p), + (Input::Point(p), Some(from)) => { + self.height_from = None; + return Ok(self.height(s, p.dist(from))); } + (Input::Text(t), None) => match crate::units::parse_distance(&t).filter(|v| v.is_finite()) { + Some(h) => return Ok(self.height(s, h)), + None => s.echo("Requires a distance."), + }, + (Input::Enter, None) => s.echo("Requires a height."), + (Input::Enter, Some(_)) => s.echo("Requires a second point."), + _ => {} } return Ok(Step::Continue); } diff --git a/crates/engine/tests/it/inquiry_keywords.rs b/crates/engine/tests/it/inquiry_keywords.rs index 1a46bc5c9..5a2c4cb6e 100644 --- a/crates/engine/tests/it/inquiry_keywords.rs +++ b/crates/engine/tests/it/inquiry_keywords.rs @@ -229,6 +229,14 @@ fn measuregeom_volume_multiplies_an_area_by_a_height() { assert!(has(&log, "Requires a distance."), "{log:?}"); assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text()); + // Enter is not a height; two points give the height as their distance. + let (mut s, log) = run(&["measuregeom", "v", "0,0", "4,0", "4,3", "", "", "1,1"]); + assert!(has(&log, "Requires a height."), "{log:?}"); + assert!(s.prompt_text().contains("Specify second point"), "{}", s.prompt_text()); + s.cmdline("7,9").unwrap(); + assert!(has(&s.log, "Volume = 60.0000"), "{:?}", s.log); + assert!(s.running.is_none(), "{}", s.prompt_text()); + // The JSON forms, by points and by handle. let mut s = Session::new(); let r = s.execute("measuregeom", &json!({"mode": "volume", "points": [[0, 0], [4, 0], [4, 3]], "height": 10})).unwrap(); From 3d7e298b7de55b8a1199765d1221e1e854732f06 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 04:26:38 +0000 Subject: [PATCH 11/13] gaps.md: F7 notes the DIMTFILL 1 and true-color DIMTFILLCLR limits of written files --- docs/gaps.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 5f8e2833e..a4a12cff8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (DIMTFILL and DIMTFILLCLR: F7 narrowed; COMPAREEXPORT exports a comparison to a new drawing: F22 narrowed) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 updated) · **Target:** Autodesk AutoCAD 2027 Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR @@ -29,7 +29,7 @@ do them first. | F3 | **[alpha blocker]** (dynamic-block authoring is beta) No dynamic-block authoring: parameters and actions can't be added in the Block Editor. Dynamic blocks from other files are read and their references edited (visibility, distances, stretch and move, lookups, grips). BEDIT (with the Edit Block Definition dialog, which also starts new blocks), BSAVE, BSAVEAS and BCLOSE edit a static block definition, and every reference updates; REFEDIT edits a reference in place; no authoring palettes yet | issue #185 offers help | High: standard libraries are dynamic | 40–65 | | F4 | Autosave leftovers: no autosave in the web build (browser storage); autosave files are DXF, not DWG; no separate RECOVER mode in the file readers (SAVETIME, Drawing Recovery, AUDIT and RECOVER landed with #406; AUDITCTL `.adt` log landed) | `engine/src/autosave.rs`, `engine/src/audit.rs` | Low-medium (web users) | 2–4 | | F5 | Associative arrays (ASsociative, Levels, polar ROWs, ARRAYEDIT, ARRAYCLOSE) landed (#448); left: grip editing of arrays (count/spacing grips), ARRAYPATH is not associative, ARRAYEDIT has no item deletion (CTRL+click) and no Key point base points, levels only shift Z (no 3D view) | `cmd/array.rs`, `cmd/array/edit.rs` | Medium | 4–6 | -| F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507; the DIMTFILL/DIMTFILLCLR text background done) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 | +| F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects; with DIMTFILL 1 the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507; the DIMTFILL/DIMTFILLCLR text background done) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 | | F8 | TOLERANCE leftovers (feature control frames landed, #420: model, our own symbol drawing, DXF, command-line prompts, explode, grips): no Geometric Tolerance dialog, no LEADER/QLEADER tolerance annotation, no in-place editing of a frame's contents | `cmd/tolerance.rs` | Low-medium (mechanical) | 2–4 | | F10 | MLEADEREDIT (add/remove leader), MLEADERALIGN and MLEADERCOLLECT landed, but a multileader has one landing and one block (a collection keeps the other blocks as block references beside it; MLEADERALIGN has no Options-menu preview); MLEADERSTYLE has 5 fields (block content and spline/no-leader types are per command, not in the style; block content has no attributes) | `annotate.rs`, `cmd/mleader_content.rs`, `tables.rs:417` | Medium | 8–12 | | F9 | MTEXT is typed in an on-canvas editor with a formatting toolbar (font, height, bold, italic, underline, overline, colour, alignment, lists, tab and special characters) and a ruler for indents and tab stops (#532); left: the box does not lay the text out by the indents, tabs, columns or font weights while typing (bold shows wider, overline is not drawn), no justification (attachment) or columns controls in the editor, existing MTEXT cannot be edited (no DDEDIT/MTEDIT), stacked fractions and other codes show as placeholders; no fields (FIELD, UPDATEFIELD) and no spell check (SPELL): open PR #551. Paragraph indents, tabs, bullets, numbering and columns landed (#547) | `ui-egui/mtext_editor.rs`, `engine/mtext_edit.rs` | Medium | 10–18 | @@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated | | 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_ vs .dwg`: this drawing's objects in one group and the other drawing's differing objects in a second group, in the comparison colors (green, red, gray for objects in both), block contents and style colors included: F22 narrowed | | 2026-10-24 | minor | DIMTFILL and DIMTFILLCLR: a dimension's text background (none, the drawing's background color, or a color) hides what is drawn under the text; SETVAR, DIMOVERRIDE and style fields, DIMSTYLE groups 69 and 70 in DXF from R2007 (CADCraft xdata before), and a SOLID in the `*D` block and from EXPLODE for DIMTFILL 2: F7 narrowed | | 2026-10-22 | minor | APPLOAD typed or from Tools ▸ Load Application opens a `.lsp` file browser starting in the last-used folder (kept in the preferences); a picked file loads interactively like a typed name (routines that ask for input wait), the web says to drop the file (no file system), and the JSON form `{path}` reads the path as given and returns the commands the file defined, never opening a dialog (#1820): E1 narrowed | From b34477d580eded0218d4abf4b3aab69a6a90014a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 04:41:25 +0000 Subject: [PATCH 12/13] cargo fmt --- crates/engine/src/cmd/file/compare/export.rs | 20 ++++++++++++++------ crates/engine/tests/it/blockreplace.rs | 8 ++++++-- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs index 5988c51d9..db41abfba 100644 --- a/crates/engine/src/cmd/file/compare/export.rs +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -455,9 +455,13 @@ fn tinted_dim_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Tint } let st = d.dim_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone(); let base = format!("COMPARE_{}_{}", tag(color), st.name); - let new = tinted.next.find("dimension style", &base, d.dim_styles.len(), |i| suffixed(&base, i), |n| { - d.dim_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)) - }); + let new = tinted.next.find( + "dimension style", + &base, + d.dim_styles.len(), + |i| suffixed(&base, i), + |n| d.dim_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)), + ); d.dim_styles.push(DimStyle { name: new.clone(), dim_line_color: Color::ByBlock, @@ -478,9 +482,13 @@ fn tinted_table_style(d: &mut Drawing, name: &str, color: Color, tinted: &mut Ti } let st = d.table_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))?.clone(); let base = format!("COMPARE_{}_{}", tag(color), st.name); - let new = tinted.next.find("table style", &base, d.table_styles.len(), |i| suffixed(&base, i), |n| { - d.table_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)) - }); + let new = tinted.next.find( + "table style", + &base, + d.table_styles.len(), + |i| suffixed(&base, i), + |n| d.table_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)), + ); let names = [CellStyle::TITLE, CellStyle::HEADER, CellStyle::DATA].into_iter().map(str::to_string).chain(st.cell_styles.iter().map(|c| c.name.clone())); let mut cell_styles: Vec = Vec::new(); diff --git a/crates/engine/tests/it/blockreplace.rs b/crates/engine/tests/it/blockreplace.rs index 2cd18c81c..86b8c42e3 100644 --- a/crates/engine/tests/it/blockreplace.rs +++ b/crates/engine/tests/it/blockreplace.rs @@ -1,8 +1,8 @@ //! 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::foreign::{Foreign, ForeignExtra, ForeignObject}; +use cadcraft_engine::doc::geom::Vec3; use cadcraft_engine::doc::{Block, Common, Entity, EntityKind, Handle, Insert, RawTag, Space}; use cadcraft_engine::{Input, Session}; use serde_json::{Value, json}; @@ -204,7 +204,11 @@ fn blockreplace_refuses_a_block_as_soon_as_its_name_is_given() { let mut f = Foreign::default(); f.records.insert(("BLOCK_RECORD".into(), "A".into()), ForeignExtra { xdict: Some(Handle(0xF00)), ..Default::default() }); let tags = |t: &[(i32, &str)]| t.iter().map(|(c, v)| RawTag { code: *c, value: (*v).into() }).collect(); - f.objects.push(ForeignObject { handle: Handle(0xF00), kind: "DICTIONARY".into(), tags: tags(&[(100, "AcDbDictionary"), (3, "ACAD_ENHANCEDBLOCK"), (360, "F01")]) }); + f.objects.push(ForeignObject { + handle: Handle(0xF00), + kind: "DICTIONARY".into(), + tags: tags(&[(100, "AcDbDictionary"), (3, "ACAD_ENHANCEDBLOCK"), (360, "F01")]), + }); f.objects.push(ForeignObject { handle: Handle(0xF01), kind: "ACAD_EVALUATION_GRAPH".into(), tags: tags(&[(100, "AcDbEvalGraph")]) }); d.foreign = std::sync::Arc::new(f); s.open_drawing(d, "dynamic", None); From c6c3aefe3f864c8420652b2a5eaa068e6cfc1bcc Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 05:06:46 +0000 Subject: [PATCH 13/13] COMPAREEXPORT: a dimension whose block can't be copied is drawn again in the comparison color; Volume's second point says it needs a point; comments say only dimensions leave anonymous block names out --- crates/engine/src/cmd/file/compare.rs | 6 +- crates/engine/src/cmd/file/compare/export.rs | 6 ++ crates/engine/src/cmd/inquiry.rs | 1 + crates/engine/tests/it/compare_export.rs | 85 ++++++++++++++++++-- 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/file/compare.rs b/crates/engine/src/cmd/file/compare.rs index d4e2878e8..3b6837342 100644 --- a/crates/engine/src/cmd/file/compare.rs +++ b/crates/engine/src/cmd/file/compare.rs @@ -144,7 +144,8 @@ struct Signer<'a> { /// given, and the /// signatures of a multileader and a dimension cover their blocks'. Each object in a block /// then also counts with its visibility, transparency, thickness and extrusion, and an - /// anonymous block's signature leaves out its name. + /// anonymous block's signature leaves out its name (a dimension also leaves its block's name + /// out of its own geometry; an INSERT or MLEADER keeps it). frames: Option>, } @@ -195,7 +196,8 @@ impl<'a> Signer<'a> { let mut h = std::collections::hash_map::DefaultHasher::new(); let block = self.d.block(name); // With frames, an anonymous block (`*D3`, `*U7`) is signed by its contents only: the - // same block can have another generated name in another drawing. + // same block can have another generated name in another drawing. Only a dimension's + // geometry leaves the name out, so this matches renamed `*D` blocks, not `*U` references. if !(self.frames.is_some() && block.is_some_and(|b| b.anonymous)) { key.hash(&mut h); } diff --git a/crates/engine/src/cmd/file/compare/export.rs b/crates/engine/src/cmd/file/compare/export.rs index db41abfba..f3801049d 100644 --- a/crates/engine/src/cmd/file/compare/export.rs +++ b/crates/engine/src/cmd/file/compare/export.rs @@ -439,6 +439,12 @@ fn paint(d: &mut Drawing, e: &mut Entity, color: Color, tinted: &mut Tinted, dep } _ => {} } + if let EntityKind::Dimension(dm) = &mut e.kind { + // A dimension whose block can't be copied (past the limits) is drawn again from its + // geometry: its DIMCLR* overrides are ByBlock, so the original block would keep its colors. + dm.block = dm.block.take().and_then(|n| tinted_block(d, &n, color, tinted, depth)); + return; + } each_ref(&mut e.kind, |n| { if let Some(copy) = tinted_block(d, n, color, tinted, depth) { *n = copy; diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index 9962c3fd6..49e0424c2 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -1054,6 +1054,7 @@ impl Interactive for AreaM { }, (Input::Enter, None) => s.echo("Requires a height."), (Input::Enter, Some(_)) => s.echo("Requires a second point."), + (Input::Text(_), Some(_)) => s.echo("Requires a point."), _ => {} } return Ok(Step::Continue); diff --git a/crates/engine/tests/it/compare_export.rs b/crates/engine/tests/it/compare_export.rs index 36afcdec6..79d28737e 100644 --- a/crates/engine/tests/it/compare_export.rs +++ b/crates/engine/tests/it/compare_export.rs @@ -351,13 +351,28 @@ fn a_named_block_whose_dimension_draws_another_block_shows_the_other_definition( .unwrap_or_default(); assert_eq!(drawn, [Some("*D2".to_string())], "the other drawing's PART is copied with its dimension block"); assert_eq!(ends(&s, "*D2"), vec![5.0]); - // The colored copies redraw their dimensions instead of keeping colored copies of `*D` blocks. + // The colored copies of PART refer to colored copies of the dimension block, which are + // anonymous `*D` blocks of their own, not the originals. let d = s.doc().unwrap(); - let dim_copy = |n: &str| { - n.starts_with("COMPARE_") - && n.rsplit('_').next().is_some_and(|t| t.len() > 1 && t.starts_with('D') && t[1..].bytes().all(|c| c.is_ascii_digit())) - }; - assert!(!d.blocks.keys().any(|n| dim_copy(n)), "{:?}", d.blocks.keys().collect::>()); + let copies: Vec = d + .blocks + .values() + .filter(|b| b.name.starts_with("COMPARE_")) + .flat_map(|b| { + b.entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Dimension(dm) => Some(dm.block.clone().unwrap_or_default()), + _ => None, + }) + .collect::>() + }) + .collect(); + assert!(!copies.is_empty(), "PART has colored copies"); + for n in &copies { + assert!(n.starts_with("*D") && !["*D1", "*D2"].contains(&n.as_str()), "{n} in {copies:?}"); + assert!(d.block(n).is_some_and(|b| b.anonymous), "{n} is an anonymous block"); + } } #[test] @@ -789,6 +804,64 @@ fn says_when_blocks_both_drawings_define_differently_were_left_out() { assert!(r["message"].as_str().unwrap().contains("some blocks of the other drawing were too many or too large to copy"), "{}", r["message"]); } +#[test] +fn a_dimension_whose_block_is_past_the_nesting_limit_is_drawn_again() { + use cadcraft_engine::doc::{DimKind, Dimension, MAX_BLOCK_DEPTH}; + // B1 holds B2, …, and the innermost block holds a dimension: its `*D1` copy would be past + // the nesting limit. + let mut s = Session::new(); + let d = s.doc_mut().unwrap(); + let mut drawn = Block::new("*D1"); + drawn.entities.push(Entity::new(Handle(0x600), line(30.0))); + d.blocks.insert("*D1".into(), Arc::new(drawn)); + let dim = Dimension { + kind: DimKind::Linear { rotation: 0.0 }, + defpt: Vec3::new(15.0, 5.0, 0.0), + text_mid: Vec3::new(7.0, 5.0, 0.0), + p13: Vec3::ZERO, + p14: Vec3::new(30.0, 0.0, 0.0), + p15: Vec3::ZERO, + p16: Vec3::ZERO, + text: String::new(), + style: "Standard".into(), + measurement: 30.0, + text_rotation: 0.0, + user_text_pos: false, + block: Some("*D1".into()), + overrides: Default::default(), + assoc: Vec::new(), + marks: Default::default(), + }; + for k in 1..=MAX_BLOCK_DEPTH { + let mut b = Block::new(&format!("B{k}")); + let kind = if k == MAX_BLOCK_DEPTH { EntityKind::Dimension(dim.clone()) } else { EntityKind::Insert(insert(&format!("B{}", k + 1))) }; + b.entities.push(Entity::new(Handle(0x700 + k as u64), kind)); + d.blocks.insert(b.name.clone(), Arc::new(b)); + } + d.add(&Space::Model, Common::default(), EntityKind::Insert(insert("B1"))).unwrap(); + s.open_drawing(Drawing::new_imperial(), "Other", None); + s.execute("document.switch", &json!({"index": 0})).unwrap(); + s.execute("compare", &json!({"document": "Other"})).unwrap(); + s.execute("compareexport", &json!({})).unwrap(); + let d = s.doc().unwrap(); + let dims: Vec> = d + .blocks + .values() + .filter(|b| b.name.starts_with("COMPARE_")) + .flat_map(|b| { + b.entities + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Dimension(dm) => Some(dm.block.clone()), + _ => None, + }) + .collect::>() + }) + .collect(); + // The colored copy keeps no block, so it is drawn in the comparison color, not with `*D1`'s. + assert_eq!(dims, [None], "{:?}", d.blocks.keys().collect::>()); +} + #[test] fn needs_a_comparison_of_the_current_drawing() { let mut s = Session::new();