Files
cadcraft/crates/engine/tests/compare_export.rs
T
dotCooCoo 4dbcbfdaaa COMPAREEXPORT: dimension block copies stay *D blocks, and image paths count in block reuse
A dimension's block took a recolored copy named COMPARE_<tag>_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.
2026-10-24 19:37:44 -07:00

849 lines
40 KiB
Rust

//! 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.
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,
}
}
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<Arc<Entity>> {
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<f64> {
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 source = s.doc().unwrap().clone();
let r = s.execute("compareexport", &json!({})).unwrap();
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<Handle> = d.model.iter().map(|e| e.handle).collect();
let groups: Vec<Handle> = 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<String> = 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<String> = 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<String> = 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<String> {
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(),
};
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<Option<String>> = 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]);
}
#[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();
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<String> = 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<f64> = 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::<Vec<_>>(), [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<Option<String>> = 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<String> = images.collect();
assert_eq!(images.len(), 2);
assert!(images.iter().all(|p| std::path::Path::new(p) == want), "{images:?}");
}
#[test]
fn relative_xref_paths_of_a_compared_file_take_that_files_folder() {
// Test file format: any bytes read as a drawing with a reference to xref Y at `y.dwg`.
fn read(_bytes: &[u8], _name: &str) -> Result<Drawing, String> {
let mut d = Drawing::new_imperial();
let mut y = Block::new("Y");
y.xref_path = Some("y.dwg".into());
d.blocks.insert("Y".into(), Arc::new(y));
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert("Y"))).map_err(|e| e.to_string())?;
Ok(d)
}
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None });
let dir = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("compareexport-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("other.dxf");
std::fs::write(&path, "x").unwrap();
// Named by its absolute path and by a path relative to the working directory.
let mut names = vec![path.to_string_lossy().to_string()];
names.extend(relative_to_cwd(&path));
for name in names {
let mut s = Session::new();
s.execute("compare", &json!({"path": name})).unwrap();
// The compared file's xref is only there; the copy keeps the file's folder.
s.execute("compareexport", &json!({})).unwrap();
let got = s.doc().unwrap().block("Y").unwrap().xref_path.clone().unwrap();
let got = std::path::Path::new(&got);
assert!(got.is_absolute(), "{got:?}, compared as {name}");
assert_eq!(got.file_name(), Some(std::ffi::OsStr::new("y.dwg")));
assert_eq!(got.parent().unwrap().canonicalize().unwrap(), dir.canonicalize().unwrap(), "compared as {name}");
}
std::fs::remove_dir_all(&dir).ok();
}
/// `p` as a path relative to the working directory (`..` steps up to the shared folder), when
/// the two share a root.
fn relative_to_cwd(p: &std::path::Path) -> Option<String> {
use std::path::Component;
let cwd = std::env::current_dir().ok()?;
let (a, b): (Vec<Component>, Vec<Component>) = (cwd.components().collect(), p.components().collect());
let shared = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
if shared == 0 {
return None;
}
let mut rel = std::path::PathBuf::new();
a[shared..].iter().for_each(|_| rel.push(".."));
b[shared..].iter().for_each(|c| rel.push(c));
Some(rel.to_string_lossy().to_string())
}
#[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 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);
}