//! COMPAREEXPORT: the comparison as a new drawing, with this drawing's objects in one group and //! the other drawing's differing objects in another, in the comparison colors. A compared file's //! relative xref paths, which need the file-reading hook, are tested in `tests/compare.rs`. use std::sync::Arc; use cadcraft_engine::Session; use cadcraft_engine::doc::color::{Color, Rgb}; use cadcraft_engine::doc::geom::Vec3; use cadcraft_engine::doc::{Block, CellStyle, Common, Drawing, Entity, EntityKind, Handle, Insert, Layer, MLeader, Space}; use serde_json::json; fn insert(block: &str) -> Insert { Insert { block: block.into(), insert: Vec3::ZERO, 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, clip: None, } } fn line(x: f64) -> EntityKind { EntityKind::Line(cadcraft_engine::doc::Line { a: Vec3::ZERO, b: Vec3::new(x, 0.0, 0.0) }) } fn handle_of(s: &Session, ty: &str) -> String { s.doc().unwrap().model.iter().find(|e| e.kind.type_name() == ty).unwrap().handle.hex() } /// "Drawing1" holds a line, a circle and a text; "B" is a copy where the circle moved, the text /// is gone and an arc was added. The comparison runs in "B" against "Drawing1". fn compared() -> Session { let mut s = Session::new(); s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); s.execute("circle", &json!({"center": [5, 5], "radius": 2})).unwrap(); s.execute("text", &json!({"at": [0, 10], "height": 1, "text": "Note"})).unwrap(); let d: Drawing = s.doc().unwrap().clone(); s.open_drawing(d, "B.dxf", None); let circle = handle_of(&s, "Circle"); let text = handle_of(&s, "Text"); s.execute("move", &json!({"handles": [circle], "from": [0, 0], "to": [3, 0]})).unwrap(); s.execute("erase", &json!({"handles": [text]})).unwrap(); s.execute("arc", &json!({"center": [20, 0], "radius": 1, "start": 0, "end": 90})).unwrap(); s.execute("compare", &json!({"document": "Drawing1"})).unwrap(); s } /// The objects of a group of the active drawing, in group order. fn members(s: &Session, group: &str) -> Vec> { let d = s.doc().unwrap(); let g = d.groups.iter().find(|g| g.name == group).unwrap_or_else(|| panic!("no group {group}")); g.members.iter().map(|h| d.entity(*h).unwrap().clone()).collect() } /// The (type, color) of each object in a group of the active drawing, sorted. fn contents(s: &Session, group: &str) -> Vec<(String, Color)> { let mut v: Vec<(String, Color)> = members(s, group).iter().map(|e| (e.kind.type_name().to_string(), e.common.color)).collect(); v.sort_by(|a, b| a.0.cmp(&b.0)); v } /// The x of each line's end in a block of the active drawing. fn ends(s: &Session, block: &str) -> Vec { let d = s.doc().unwrap(); d.block(block) .unwrap() .entities .iter() .filter_map(|e| match &e.kind { EntityKind::Line(l) => Some(l.b.x), _ => None, }) .collect() } const GREEN: Color = Color::True(Rgb(0x4c, 0xd9, 0x7b)); const RED: Color = Color::True(Rgb(0xff, 0x5c, 0x5c)); const GRAY: Color = Color::Index(8); #[test] fn exports_both_drawings_as_colored_groups_in_a_new_drawing() { let mut s = compared(); let before = s.docs.len(); let before_epoch = s.doc_epoch; let source = s.doc().unwrap().clone(); let r = s.execute("compareexport", &json!({})).unwrap(); assert!(s.doc_epoch > before_epoch, "opening the exported drawing invalidates queued captures"); assert_eq!(s.docs.len(), before + 1, "{r}"); assert_eq!(r["title"], "compare_B vs Drawing1.dwg", "{r}"); assert_eq!(s.state().unwrap().title, "compare_B vs Drawing1.dwg"); assert_eq!(s.state().unwrap().path, None, "the snapshot is untitled"); assert!(s.state().unwrap().is_dirty(), "closing the snapshot asks to save it"); assert_eq!((r["counts"]["current"].as_u64(), r["counts"]["compared"].as_u64()), (Some(3), Some(2)), "{r}"); assert_eq!(r["truncated"], false); let (cur, other) = (r["current"].as_str().unwrap(), r["compared"].as_str().unwrap()); assert_eq!((cur, other), ("COMPARE_CURRENT", "COMPARE_COMPARED")); // This drawing: the moved circle and the new arc in green, the unchanged line in gray. assert_eq!(contents(&s, cur), vec![("Arc".into(), GREEN), ("Circle".into(), GREEN), ("Line".into(), GRAY)]); // The other drawing: the circle where it was and the erased text, in red, in its draw order. assert_eq!(contents(&s, other), vec![("Circle".into(), RED), ("Text".into(), RED)]); let order: Vec<&str> = members(&s, other).iter().map(|e| e.kind.type_name()).collect(); assert_eq!(order, ["Circle", "Text"]); let circle_there = members(&s, other).iter().find_map(|e| match &e.kind { EntityKind::Circle(c) => Some(c.center), _ => None, }); assert_eq!(circle_there.map(|c| (c.x, c.y)), Some((5.0, 5.0))); // Model space holds the other drawing's objects first, then this drawing's. let d = s.doc().unwrap(); let order: Vec = d.model.iter().map(|e| e.handle).collect(); let groups: Vec = members(&s, other).iter().chain(members(&s, cur).iter()).map(|e| e.handle).collect(); assert_eq!(order, groups); // Every handle is new and unique. let mut seen = std::collections::HashSet::new(); for e in d.model.iter().chain(d.blocks.values().flat_map(|b| b.entities.iter())) { assert!(seen.insert(e.handle), "handle {} repeats", e.handle.hex()); } // The drawing COMPARE ran in is unchanged, and the comparison stays on it. let back = s.docs.iter().position(|d| d.title == "B.dxf").unwrap(); assert!(*s.docs[back].doc == source); assert!(s.compare.is_some()); } #[test] fn takes_the_other_drawings_layers_and_blocks_it_needs() { let mut s = Session::new(); s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); // The other drawing holds a reference to a block this one lacks, on a layer this one lacks. let mut other = s.doc().unwrap().clone(); other.handseed = 0x9000; let mut part = Block::new("PART"); part.entities.push(Entity::new(Handle(0x8000), EntityKind::Insert(insert("BOLT")))); let mut bolt = Block::new("BOLT"); bolt.entities.push(Entity::new(Handle(0x8001), line(1.0))); other.blocks.insert("PART".into(), Arc::new(part)); other.blocks.insert("BOLT".into(), Arc::new(bolt)); other.layers.push(Layer::new("Parts")); other.add(&Space::Model, Common { layer: "Parts".into(), ..Common::default() }, EntityKind::Insert(insert("PART"))).unwrap(); // A group of this drawing's name does not carry over, so the snapshot takes the first name. s.execute("group", &json!({"name": "COMPARE_CURRENT", "handles": [handle_of(&s, "Line")]})).unwrap(); s.open_drawing(other, "Other", None); s.execute("document.switch", &json!({"index": 0})).unwrap(); s.execute("compare", &json!({"document": "Other"})).unwrap(); let r = s.execute("compareexport", &json!({})).unwrap(); assert_eq!(r["current"], "COMPARE_CURRENT", "{r}"); let d = s.doc().unwrap(); assert_eq!(d.groups.len(), 2); assert!(d.layer("Parts").is_some()); assert!(d.block("PART").is_some() && d.block("BOLT").is_some(), "nested blocks are copied"); assert_eq!(ends(&s, "COMPARE_COMPARED_BOLT"), vec![1.0], "and shown through red copies"); assert!(d.blocks.values().flat_map(|b| b.entities.iter()).all(|e| e.handle.0 < 0x8000 || e.handle.0 >= 0x9000)); let refs: Vec = members(&s, "COMPARE_COMPARED") .iter() .filter_map(|e| match &e.kind { EntityKind::Insert(i) => Some(format!("{} on {}", i.block, e.common.layer)), _ => None, }) .collect(); assert_eq!(refs, vec!["COMPARE_COMPARED_PART on Parts".to_string()]); } #[test] fn anonymous_blocks_of_the_other_drawing_keep_their_own_definition() { let mut s = Session::new(); let mut mine = Block::new("*U1"); mine.entities.push(Entity::new(Handle(0x700), line(1.0))); s.doc_mut().unwrap().blocks.insert("*U1".into(), Arc::new(mine)); s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("*U1"))).unwrap(); // The other drawing uses the same anonymous name for other geometry. let mut other = Drawing::new_imperial(); other.handseed = 0x9000; let mut theirs = Block::new("*U1"); theirs.entities.push(Entity::new(Handle(0x8000), line(5.0))); other.blocks.insert("*U1".into(), Arc::new(theirs)); other.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { insert: Vec3::new(9.0, 9.0, 0.0), ..insert("*U1") })).unwrap(); s.open_drawing(other, "Other", None); s.execute("document.switch", &json!({"index": 0})).unwrap(); s.execute("compare", &json!({"document": "Other"})).unwrap(); let r = s.execute("compareexport", &json!({})).unwrap(); assert_eq!(ends(&s, "*U1"), vec![1.0], "this drawing's definition is kept"); let referenced: Vec = members(&s, r["compared"].as_str().unwrap()) .iter() .filter_map(|e| match &e.kind { EntityKind::Insert(i) => Some(i.block.clone()), _ => None, }) .collect(); // The other drawing's `*U1` is copied as `*U2`, and the reference shows it through a red copy. assert_eq!(ends(&s, "*U2"), vec![5.0]); assert_eq!(referenced, vec!["COMPARE_COMPARED_U2".to_string()], "{r}"); assert_eq!(ends(&s, "COMPARE_COMPARED_U2"), vec![5.0]); assert_eq!(ends(&s, "COMPARE_CURRENT_U1"), vec![1.0]); } #[test] fn a_named_block_defined_differently_in_the_other_drawing_shows_the_other_definition() { // PART holds a line, a reference to SUB, which holds a line of length 2, and a multileader // whose content is SYMBOL, which holds one line. let blocks = |part_end: f64, part_base: f64, sub_base: f64, symbol_end: f64, first: u64| { let mut part = Block::new("PART"); part.base = Vec3::new(part_base, 0.0, 0.0); part.entities.push(Entity::new(Handle(first), line(part_end))); part.entities.push(Entity::new(Handle(first + 1), EntityKind::Insert(insert("SUB")))); let leader = MLeader { leaders: Vec::new(), landing: Vec3::ZERO, dogleg: 0.0, text: None, style: "Standard".into(), arrow_size: 1.0, spline: false, block: Some(insert("SYMBOL")), }; part.entities.push(Entity::new(Handle(first + 2), EntityKind::MLeader(leader))); let mut sub = Block::new("SUB"); sub.base = Vec3::new(sub_base, 0.0, 0.0); sub.entities.push(Entity::new(Handle(first + 3), line(2.0))); let mut symbol = Block::new("SYMBOL"); symbol.entities.push(Entity::new(Handle(first + 4), line(symbol_end))); [part, sub, symbol] }; let run = |theirs_end: f64, theirs_base: f64, theirs_sub_base: f64, theirs_symbol_end: f64| { let mut s = Session::new(); for b in blocks(1.0, 0.0, 0.0, 3.0, 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(theirs_end, theirs_base, theirs_sub_base, theirs_symbol_end, 0x8000) { other.blocks.insert(b.name.clone(), Arc::new(b)); } // A second reference, so the other drawing has one that differs even when PART does not. 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(); let r = s.execute("compareexport", &json!({})).unwrap(); let referenced: Vec = members(&s, r["compared"].as_str().unwrap()) .iter() .filter_map(|e| match &e.kind { EntityKind::Insert(i) => Some(i.block.clone()), _ => None, }) .collect(); (s, referenced) }; // The other drawing's PART draws a longer line: it is copied as PART_2 and shown in red. let (s, referenced) = run(5.0, 0.0, 0.0, 3.0); assert_eq!(ends(&s, "PART"), vec![1.0], "this drawing's definition is kept"); assert_eq!(ends(&s, "PART_2"), vec![5.0]); assert_eq!(referenced, vec!["COMPARE_COMPARED_PART_2".to_string()]); assert_eq!(ends(&s, "COMPARE_COMPARED_PART_2"), vec![5.0]); let d = s.doc().unwrap(); assert!(d.block("SUB_2").is_none() && d.block("SYMBOL_2").is_none(), "SUB and SYMBOL are the same in both drawings"); // The blocks a copy of PART refers to. let refers = |s: &Session| -> Vec { s.doc() .unwrap() .block("PART_2") .unwrap() .entities .iter() .flat_map(|e| match &e.kind { EntityKind::Insert(i) => vec![i.block.clone()], EntityKind::MLeader(m) => m.block.iter().map(|b| b.block.clone()).collect(), _ => Vec::new(), }) .collect() }; // Another base point on PART, another base point on the SUB inside it, or another SYMBOL // in its multileader: PART is copied too, with the blocks that differ. let (s, referenced) = run(1.0, 3.0, 0.0, 3.0); assert_eq!(s.doc().unwrap().block("PART_2").map(|b| b.base.x), Some(3.0)); assert_eq!(referenced, vec!["COMPARE_COMPARED_PART_2".to_string()]); assert_eq!(refers(&s), ["SUB", "SYMBOL"]); let (s, _) = run(1.0, 0.0, 4.0, 3.0); assert_eq!(s.doc().unwrap().block("SUB_2").map(|b| b.base.x), Some(4.0)); assert_eq!(refers(&s), ["SUB_2", "SYMBOL"]); let (s, _) = run(1.0, 0.0, 0.0, 6.0); assert_eq!(ends(&s, "SYMBOL_2"), vec![6.0]); assert_eq!(refers(&s), ["SUB", "SYMBOL_2"]); // The same definitions in both drawings: the red reference uses this drawing's PART. let (s, referenced) = run(1.0, 0.0, 0.0, 3.0); assert!(s.doc().unwrap().block("PART_2").is_none()); assert_eq!(referenced, vec!["COMPARE_COMPARED_PART".to_string()]); assert_eq!(ends(&s, "COMPARE_COMPARED_PART"), vec![1.0]); } #[test] fn a_named_block_whose_dimension_draws_another_block_shows_the_other_definition() { use cadcraft_engine::doc::{DimKind, Dimension}; // PART holds a dimension with the same parameters in both drawings; the block it draws, `*D1`, // holds a line of another length in the other drawing. 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(), insertion_point: Vec3::ZERO, }; let blocks = |dim_end: f64, first: u64| { let mut part = Block::new("PART"); part.entities.push(Entity::new(Handle(first), EntityKind::Dimension(dim.clone()))); let mut drawn = Block::new("*D1"); drawn.entities.push(Entity::new(Handle(first + 1), line(dim_end))); [part, drawn] }; let mut s = Session::new(); for b in blocks(1.0, 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(5.0, 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(); assert_eq!(ends(&s, "*D1"), vec![1.0], "this drawing's dimension block is kept"); let d = s.doc().unwrap(); let drawn: Vec> = d .block("PART_2") .map(|b| { b.entities .iter() .filter_map(|e| match &e.kind { EntityKind::Dimension(dm) => Some(dm.block.clone()), _ => None, }) .collect() }) .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 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 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] 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(), insertion_point: Vec3::ZERO, }; 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(); assert!(s.doc().unwrap().block("PART_2").is_none(), "PART draws the same thing in both drawings"); } #[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 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(), insertion_point: Vec3::ZERO, }; 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(); let mut part = Block::new("PART"); part.entities.push(Entity { handle: Handle(0x700), common: Common { color: Color::Index(1), ..Common::default() }, kind: line(1.0) }); part.entities.push(Entity::new(Handle(0x701), EntityKind::Insert(insert("PART")))); s.doc_mut().unwrap().blocks.insert("PART".into(), Arc::new(part)); s.doc_mut().unwrap().add(&Space::Model, Common::default(), EntityKind::Insert(insert("PART"))).unwrap(); let mut other = s.doc().unwrap().clone(); other.model = Default::default(); s.open_drawing(other, "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 objs = members(&s, r["current"].as_str().unwrap()); let EntityKind::Insert(i) = &objs[0].kind else { panic!("{r}") }; assert_eq!(i.block, "COMPARE_CURRENT_PART"); let d = s.doc().unwrap(); let copy = d.block("COMPARE_CURRENT_PART").unwrap(); assert!(copy.entities.iter().all(|e| e.common.color == GREEN), "the line inside is green too"); // The block that refers to itself refers to its own copy, which ends the copying. assert!(copy.entities.iter().any(|e| matches!(&e.kind, EntityKind::Insert(n) if n.block == "COMPARE_CURRENT_PART"))); // This drawing's definition is left as it was. assert_eq!(d.block("PART").unwrap().entities.iter().next().unwrap().common.color, Color::Index(1)); } #[test] fn colors_carried_apart_from_the_object_color_are_replaced() { let mut s = Session::new(); s.execute("mtext", &json!({"at": [0, 0], "height": 1, "width": 20, "text": "a{\\C1;red} \\c16711680;b \\\\C"})).unwrap(); s.execute("dimlinear", &json!({"p1": [0, 0], "p2": [10, 0], "at": [5, 3]})).unwrap(); s.execute("table", &json!({"at": [0, 20], "rows": 1, "cols": 1, "cells": [["\\C1;x"]]})).unwrap(); s.execute("tolerance", &json!({"at": [0, 30], "symbol": "flatness", "tol1": "0.1"})).unwrap(); s.execute("mline", &json!({"points": [[0, 40], [10, 40]]})).unwrap(); let d = s.doc_mut().unwrap(); d.dim_styles[0].dim_line_color = Color::Index(1); d.dim_styles[0].text_color = Color::Index(5); let mut data = d.table_styles[0].cell_style(CellStyle::DATA); (data.text_color, data.fill, data.borders.top.color) = (Color::Index(1), Some(Color::Index(2)), Color::Index(3)); d.table_styles[0].cell_styles.push(data); let mline = d.model.iter().find(|e| matches!(e.kind, EntityKind::MLine(_))).unwrap().handle; d.modify_entity(mline, |e| { if let EntityKind::MLine(m) = &mut e.kind { m.def.elements.iter_mut().for_each(|el| el.color = Color::Index(3)); m.def.fill = Some(Color::Index(4)); } }) .unwrap(); let other = Drawing::new_imperial(); s.open_drawing(other, "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 mut kinds = Vec::new(); let d = s.doc().unwrap(); for e in members(&s, r["current"].as_str().unwrap()) { match &e.kind { EntityKind::MText(t) => assert_eq!(t.contents, "a{red} b \\\\C", "inline colors are taken out"), EntityKind::Dimension(dm) => { let st = d.dim_style(&dm.style).unwrap().with_overrides(&dm.overrides); assert_eq!((st.dim_line_color, st.ext_line_color, st.text_color), (Color::ByBlock, Color::ByBlock, Color::ByBlock)); } EntityKind::Table(t) => { assert_eq!(t.style, "COMPARE_CURRENT_Standard"); assert_eq!(t.cells.iter().flatten().find(|c| !c.text.is_empty()).map(|c| c.text.as_str()), Some("x")); let st = d.table_styles.iter().find(|s| s.name == t.style).unwrap(); let data = st.cell_style(CellStyle::DATA); assert_eq!((data.text_color, data.fill, data.borders.top.color), (Color::ByBlock, None, Color::ByBlock)); } EntityKind::Tolerance(t) => { assert_eq!(t.style, "COMPARE_CURRENT_Standard"); let st = d.dim_style(&t.style).unwrap(); assert_eq!((st.dim_line_color, st.text_color), (Color::ByBlock, Color::ByBlock)); } EntityKind::MLine(m) => { assert!(m.def.elements.iter().all(|el| el.color == Color::ByBlock)); assert_eq!(m.def.fill, None); } _ => continue, } kinds.push(e.kind.type_name()); } assert_eq!(kinds.len(), 5, "{kinds:?}"); // This drawing's styles are left as they were. assert_eq!(d.table_styles[0].cell_style(CellStyle::DATA).text_color, Color::Index(1)); 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(); s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); let copy: Drawing = s.doc().unwrap().clone(); s.open_drawing(copy, "Other", None); s.execute("document.switch", &json!({"index": 0})).unwrap(); s.execute("compare", &json!({"document": "Other"})).unwrap(); // A line drawn after COMPARE ran is only in this drawing. s.execute("line", &json!({"points": [[0, 5], [10, 5]]})).unwrap(); let r = s.execute("compareexport", &json!({})).unwrap(); let mut got = contents(&s, r["current"].as_str().unwrap()); got.sort_by_key(|(_, c)| *c == GRAY); assert_eq!(got, vec![("Line".into(), GREEN), ("Line".into(), GRAY)], "{r}"); // The copies sit in model space on their own layer, not inside a block. let d = s.doc().unwrap(); assert!(d.model.iter().all(|e| e.common.layer == "0" && matches!(e.kind, EntityKind::Line(_)))); } #[test] fn copies_drop_associations_to_the_source_objects() { let mut s = Session::new(); s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); let l = handle_of(&s, "Line"); s.execute("dimlinear", &json!({"object": l, "at": [5, 3]})).unwrap(); let assoc = |d: &Drawing| d.model.iter().find_map(|e| if let EntityKind::Dimension(dm) = &e.kind { Some(dm.assoc.len()) } else { None }); assert!(assoc(s.doc().unwrap()).is_some_and(|n| n > 0), "the dimension follows the line"); 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(); assert_eq!(assoc(s.doc().unwrap()), Some(0)); } #[test] fn relative_xref_paths_become_paths_from_the_source_folder() { let folder = std::env::temp_dir().join("cadcraft-compareexport"); let host = folder.join("host.dxf"); let mut d = Drawing::new_imperial(); let mut x = Block::new("X"); x.xref_path = Some("parts/x.dwg".into()); d.blocks.insert("X".into(), Arc::new(x)); d.add(&Space::Model, Common::default(), EntityKind::Insert(insert("X"))).unwrap(); let image = cadcraft_engine::doc::Image { path: "img/a.png".into(), ..Default::default() }; d.add(&Space::Model, Common::default(), EntityKind::Image(image.clone())).unwrap(); let mut b = Block::new("PIC"); b.entities.push(Entity::new(Handle(0x900), EntityKind::Image(image))); d.blocks.insert("PIC".into(), Arc::new(b)); let mut s = Session::new(); s.open_drawing(d, "host.dxf", Some(host.to_string_lossy().to_string())); s.open_drawing(Drawing::new_imperial(), "Other", None); s.execute("document.switch", &json!({"index": 1})).unwrap(); s.execute("compare", &json!({"document": "Other"})).unwrap(); s.execute("compareexport", &json!({})).unwrap(); let d = s.doc().unwrap(); let got = d.block("X").unwrap().xref_path.clone().unwrap(); assert_eq!(std::path::Path::new(&got), folder.join("parts").join("x.dwg")); // Images in model space and in blocks too. let want = folder.join("img").join("a.png"); let images = d.model.iter().chain(d.block("PIC").unwrap().entities.iter()).filter_map(|e| match &e.kind { EntityKind::Image(i) => Some(i.path.clone()), _ => None, }); let images: Vec = images.collect(); assert_eq!(images.len(), 2); assert!(images.iter().all(|p| std::path::Path::new(p) == want), "{images:?}"); } #[test] fn says_when_blocks_of_the_other_drawing_were_left_out() { let mut other = Drawing::new_imperial(); for i in 0..10_001 { let name = format!("B{i}"); let mut b = Block::new(&name); b.entities.push(Entity::new(Handle(0x10_0000 + i), line(1.0))); other.blocks.insert(name.clone(), Arc::new(b)); other.add(&Space::Model, Common::default(), EntityKind::Insert(insert(&name))).unwrap(); } other.handseed = other.handseed.max(0x20_0000); let mut s = Session::new(); s.open_drawing(other, "Other", None); s.execute("document.switch", &json!({"index": 0})).unwrap(); s.execute("compare", &json!({"document": "Other"})).unwrap(); let r = s.execute("compareexport", &json!({})).unwrap(); assert_eq!(r["truncated"], true, "{}", r["message"]); assert!(r["message"].as_str().unwrap().contains("some blocks of the other drawing were too many or too large to copy"), "{}", r["message"]); // The same blocks in this drawing, compared with an empty one: past the limit of recolored // copies some references keep their blocks' own colors. let ours = s.docs[1].doc.as_ref().clone(); let mut s = Session::new(); s.open_drawing(ours, "Ours", None); s.execute("compare", &json!({"document": 0})).unwrap(); let r = s.execute("compareexport", &json!({})).unwrap(); assert_eq!(r["truncated"], true, "{}", r["message"]); assert!(r["message"].as_str().unwrap().contains("some blocks keep their own colors"), "{}", r["message"]); } #[test] fn says_when_blocks_both_drawings_define_differently_were_left_out() { // Both drawings define B0 to B10000, each with a line of another length, so past the block // limit the blocks left are ones this drawing defines differently. let blocks = |end: f64, first: u64, d: &mut Drawing| { for i in 0..10_001 { let name = format!("B{i}"); let mut b = Block::new(&name); b.entities.push(Entity::new(Handle(first + i), line(end))); d.blocks.insert(name, Arc::new(b)); } }; let mut other = Drawing::new_imperial(); blocks(1.0, 0x10_0000, &mut other); for i in 0..10_001 { other.add(&Space::Model, Common::default(), EntityKind::Insert(insert(&format!("B{i}")))).unwrap(); } other.handseed = other.handseed.max(0x20_0000); let mut s = Session::new(); let mut mine = s.doc().unwrap().clone(); blocks(2.0, 0x30_0000, &mut mine); mine.handseed = mine.handseed.max(0x40_0000); s.open_drawing(mine, "Mine", None); s.open_drawing(other, "Other", None); s.execute("document.switch", &json!({"index": 1})).unwrap(); s.execute("compare", &json!({"document": "Other"})).unwrap(); let r = s.execute("compareexport", &json!({})).unwrap(); 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(), insertion_point: Vec3::ZERO, }; 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(); assert!(s.execute("compareexport", &json!({})).is_err(), "no comparison yet"); let mut s = compared(); s.execute("document.switch", &json!({"index": 0})).unwrap(); assert!(s.execute("compareexport", &json!({})).is_err(), "the comparison belongs to the other drawing"); s.execute("document.switch", &json!({"index": 1})).unwrap(); s.execute("compareclose", &json!({})).unwrap(); assert!(s.execute("compareexport", &json!({})).is_err(), "closed"); // Typed at the command line it runs too. let mut s = compared(); let before = s.docs.len(); s.cmdline("compareexport").unwrap(); assert_eq!(s.docs.len(), before + 1); }