use std::collections::HashMap;
use std::io::Cursor;
use acadrust::entities::{Circle, EntityType, Line};
use acadrust::objects::{
Dictionary, DictionaryWithDefault, ObjectType, PlaceHolder, XRecord, XRecordEntry, XRecordValue,
};
use acadrust::types::{DxfVersion, Handle, Vector3};
use acadrust::{CadDocument, DwgReader, DwgWriter, DxfReader, DxfWriter};
fn write_dxf(doc: &CadDocument) -> Vec<u8> {
DxfWriter::new(doc).write_to_vec().unwrap()
}
fn read_dxf(bytes: &[u8]) -> CadDocument {
DxfReader::from_reader(Cursor::new(bytes.to_vec()))
.unwrap()
.read()
.unwrap()
}
fn sample_document(version: DxfVersion) -> CadDocument {
let mut doc = CadDocument::with_version(version);
doc.add_entity(EntityType::Line(Line::from_coords(0.0, 0.0, 0.0, 10.0, 10.0, 0.0)))
.unwrap();
doc.add_entity(EntityType::Circle(Circle::from_coords(5.0, 5.0, 0.0, 2.0)))
.unwrap();
let nod_handle = doc.header.named_objects_dict_handle;
let x1_handle = doc.allocate_handle();
let x2_handle = doc.allocate_handle();
let ext_handle = doc.allocate_handle();
let mut x1 = XRecord::new();
x1.handle = x1_handle;
x1.owner = ext_handle;
x1.entries.push(XRecordEntry {
code: 1,
value: XRecordValue::String("first".to_string()),
});
doc.objects.insert(x1_handle, ObjectType::XRecord(x1));
let mut x2 = XRecord::new();
x2.handle = x2_handle;
x2.owner = ext_handle;
x2.entries.push(XRecordEntry {
code: 1,
value: XRecordValue::String("second".to_string()),
});
doc.objects.insert(x2_handle, ObjectType::XRecord(x2));
let mut ext = Dictionary::new();
ext.handle = ext_handle;
ext.owner = nod_handle;
ext.hard_owner = true;
ext.add_entry("MY_XREC_ONE", x1_handle);
ext.add_entry("MY_XREC_TWO", x2_handle);
doc.objects.insert(ext_handle, ObjectType::Dictionary(ext));
if let Some(ObjectType::Dictionary(nod)) = doc.objects.get_mut(&nod_handle) {
nod.add_entry("MY_EXTENSION", ext_handle);
}
doc
}
#[test]
fn dxf_read_is_deterministic() {
for version in [DxfVersion::AC1015, DxfVersion::AC1032] {
let bytes = write_dxf(&sample_document(version));
let first = read_dxf(&bytes);
for i in 0..25 {
let other = read_dxf(&bytes);
assert_eq!(
first, other,
"{version:?}: read iteration {i} differs from the first read"
);
}
}
}
#[test]
fn hard_owner_dictionary_survives_roundtrip() {
let doc1 = read_dxf(&write_dxf(&sample_document(DxfVersion::AC1032)));
let doc2 = read_dxf(&write_dxf(&doc1));
let doc3 = read_dxf(&write_dxf(&doc2));
for (handle, object) in doc1.objects.iter() {
if let ObjectType::Dictionary(dict) = object {
if !dict.hard_owner {
continue;
}
let rt = doc2.objects.get(handle).expect("dictionary lost");
if let ObjectType::Dictionary(rt_dict) = rt {
assert_eq!(dict.entries, rt_dict.entries, "entries changed");
for (key, _) in &dict.entries {
assert!(
rt_dict.is_entry_hard_owner(key),
"entry {key:?} lost its hard-owner marking"
);
}
}
}
}
assert_eq!(
doc1.objects, doc2.objects,
"OBJECTS section changed after one DXF round-trip"
);
assert_eq!(doc2, doc3, "round-trip cycle 2 != cycle 1");
}
#[test]
fn nod_soft_entries_stay_soft_on_roundtrip() {
let bytes = write_dxf(&sample_document(DxfVersion::AC1032));
let text = String::from_utf8(bytes.clone()).unwrap();
assert!(
text.contains(" 3\r\nACAD_LAYOUT\r\n350\r\n"),
"expected soft 350 entries in output"
);
let doc = read_dxf(&bytes);
let nod_handle = doc.header.named_objects_dict_handle;
if let Some(ObjectType::Dictionary(nod)) = doc.objects.get(&nod_handle) {
assert!(
!nod.is_entry_hard_owner("ACAD_LAYOUT"),
"ACAD_LAYOUT must stay a soft pointer"
);
assert!(
!nod.is_entry_hard_owner("ACAD_PLOTSTYLENAME"),
"ACAD_PLOTSTYLENAME must stay a soft pointer"
);
assert!(
!nod.is_entry_hard_owner("ACAD_GROUP"),
"ACAD_GROUP stays a soft pointer"
);
} else {
panic!("root NOD missing");
}
}
#[test]
fn acad_field_entry_is_canonicalized_on_read() {
let mut doc = sample_document(DxfVersion::AC1032);
let nod_handle = doc.header.named_objects_dict_handle;
let xrec = doc
.objects
.iter()
.find_map(|(h, o)| matches!(o, ObjectType::XRecord(_)).then_some(*h))
.expect("sample has an XRecord");
if let Some(ObjectType::Dictionary(nod)) = doc.objects.get_mut(&nod_handle) {
nod.add_entry("ACAD_FIELD", xrec);
}
let bytes = write_dxf(&doc);
let text = String::from_utf8(bytes.clone()).unwrap();
let patched = text.replace(" 3\r\nACAD_FIELD\r\n360\r\n", " 3\r\nACAD_FIELD\r\n350\r\n");
assert_ne!(text, patched, "expected a hard-owner ACAD_FIELD in output");
let doc = read_dxf(patched.as_bytes());
let nod_handle = doc.header.named_objects_dict_handle;
if let Some(ObjectType::Dictionary(nod)) = doc.objects.get(&nod_handle) {
assert!(
nod.is_entry_hard_owner("ACAD_FIELD"),
"ACAD_FIELD must be canonicalized to hard-owner on read"
);
} else {
panic!("root NOD missing");
}
}
fn mlinestyle_angle_codes(bytes: &[u8], handle: &str) -> (f64, f64) {
let text = String::from_utf8(bytes.to_vec()).unwrap();
let lines: Vec<&str> = text.split("\r\n").collect();
let read_code = |code: &str| -> f64 {
for i in 0..lines.len().saturating_sub(2) {
if lines[i] == "MLINESTYLE" && lines[i + 1].trim() == "5" {
if !lines[i + 2].trim().eq_ignore_ascii_case(handle) {
continue;
}
let mut j = i;
while j < lines.len() - 1 {
if lines[j].trim() == code {
return lines[j + 1].trim().parse::<f64>().unwrap_or(f64::NAN);
}
j += 1;
}
}
}
f64::NAN
};
(read_code("51"), read_code("52"))
}
#[test]
fn mlinestyle_angles_stable_across_roundtrips() {
let mut doc = CadDocument::with_version(DxfVersion::AC1032);
let mut style = acadrust::objects::MLineStyle::new("TESTSTYLE");
style.handle = doc.allocate_handle();
style.start_angle = std::f64::consts::FRAC_PI_2; style.end_angle = std::f64::consts::FRAC_PI_4; let style_handle = style.handle;
doc.objects
.insert(style_handle, ObjectType::MLineStyle(style));
let bytes = write_dxf(&doc);
let (start_deg, end_deg) = mlinestyle_angle_codes(&bytes, &format!("{:X}", style_handle.value()));
assert!((start_deg - 90.0).abs() < 1e-9, "start angle must be degrees, got {start_deg}");
assert!((end_deg - 45.0).abs() < 1e-9, "end angle must be degrees, got {end_deg}");
let doc1 = read_dxf(&bytes);
let doc2 = read_dxf(&write_dxf(&doc1));
let doc3 = read_dxf(&write_dxf(&doc2));
let get_angle = |doc: &CadDocument| -> (f64, f64) {
match doc.objects.get(&style_handle) {
Some(ObjectType::MLineStyle(style)) => (style.start_angle, style.end_angle),
_ => panic!("MLineStyle lost"),
}
};
let a = get_angle(&doc1);
let b = get_angle(&doc2);
let c = get_angle(&doc3);
assert!((a.0 - std::f64::consts::FRAC_PI_2).abs() < 1e-9, "start angle drifted: {:?}", a);
assert!((a.1 - std::f64::consts::FRAC_PI_4).abs() < 1e-9, "end angle drifted: {:?}", a);
assert_eq!(a, b, "angles changed after one round-trip");
assert_eq!(b, c, "angles changed after two round-trips");
}
#[test]
fn dwg_to_dxf_pipeline_is_stable() {
let doc = sample_document(DxfVersion::AC1032);
let dwg_bytes = DwgWriter::write_to_vec(&doc).unwrap();
let from_dwg = DwgReader::from_stream(Cursor::new(dwg_bytes)).read().unwrap();
let dxf_bytes = write_dxf(&from_dwg);
let a = read_dxf(&dxf_bytes);
let b = read_dxf(&dxf_bytes);
assert_eq!(a, b, "two reads of the same generated DXF differ");
let doc2 = read_dxf(&write_dxf(&a));
assert_eq!(a.objects, doc2.objects, "OBJECTS changed across round-trip");
}
#[test]
fn objects_maps_are_content_comparable() {
let mut m1: HashMap<Handle, ObjectType> = HashMap::new();
let mut m2: HashMap<Handle, ObjectType> = HashMap::new();
let mut d = Dictionary::new();
d.handle = Handle::new(1);
m1.insert(Handle::new(1), ObjectType::Dictionary(d.clone()));
m2.insert(Handle::new(1), ObjectType::Dictionary(d));
assert_eq!(m1, m2);
}
#[test]
#[test]
fn remaps_dictionary_with_default_handle() {
let mut document = CadDocument::new();
let colliding = Handle::new(0x100);
let mut line = Line::from_points(Vector3::ZERO, Vector3::UNIT_X);
line.common.handle = colliding;
document.add_entity(EntityType::Line(line)).unwrap();
let mut placeholder = PlaceHolder::new();
placeholder.handle = colliding;
placeholder.owner = colliding;
document
.objects
.insert(colliding, ObjectType::PlaceHolder(placeholder));
let dwd_handle = Handle::new(0x200);
let mut dwd = DictionaryWithDefault::new();
dwd.handle = dwd_handle;
dwd.owner = Handle::new(0x0C);
dwd.entries.push(("Normal".to_string(), colliding));
dwd.default_handle = colliding;
document
.objects
.insert(dwd_handle, ObjectType::DictionaryWithDefault(dwd));
document.resolve_references();
match document.objects.get(&dwd_handle) {
Some(ObjectType::DictionaryWithDefault(d)) => {
assert_ne!(
d.default_handle, colliding,
"default_handle was not remapped with its object"
);
assert_eq!(d.entries[0].1, d.default_handle);
assert!(
matches!(
document.objects.get(&d.default_handle),
Some(ObjectType::PlaceHolder(_))
),
"default_handle points at a handle that no longer exists"
);
}
other => panic!("dictionary with default lost: {:?}", other.is_some()),
}
}
#[test]
fn surviving_default_entries_are_rehandled_on_read() {
let mut doc = CadDocument::new();
let standard_handle = doc.dim_styles.get("Standard").unwrap().handle;
assert!(doc.dim_styles.remove("Standard").is_some());
if let Some(br) = doc.block_records.get_mut("*Paper_Space") {
br.handle = standard_handle;
}
doc.header.paper_space_block_handle = standard_handle;
let bytes = write_dxf(&doc);
let rt = read_dxf(&bytes);
let paper_space = rt
.block_records
.get("*Paper_Space")
.expect("*Paper_Space lost")
.handle;
assert_eq!(paper_space, standard_handle, "file handle must be preserved");
let standard = rt
.dim_styles
.get("Standard")
.expect("default Standard dimstyle missing");
assert_ne!(
standard.handle, paper_space,
"default Standard dimstyle kept the colliding handle"
);
let text = String::from_utf8(write_dxf(&rt)).unwrap();
let needle = format!("\r\n 5\r\n{:X}\r\n", standard_handle.value());
assert_eq!(
text.matches(&needle).count(),
1,
"duplicate handle {:#X} in round-tripped output",
standard_handle.value()
);
}
#[test]
fn visualstyle_record_carries_code_291() {
for version in [
DxfVersion::AC1015,
DxfVersion::AC1018,
DxfVersion::AC1021,
DxfVersion::AC1024,
DxfVersion::AC1027,
DxfVersion::AC1032,
] {
let mut doc = CadDocument::with_version(version);
let mut style = acadrust::objects::VisualStyle::new();
style.handle = doc.allocate_handle();
doc.objects
.insert(style.handle, ObjectType::VisualStyle(style));
let text = String::from_utf8(write_dxf(&doc)).unwrap();
let lines: Vec<&str> = text.split("\r\n").collect();
let mut found = false;
for i in 0..lines.len().saturating_sub(1) {
if lines[i] == "VISUALSTYLE" && lines[i + 1].trim() == "5" {
let record_end = lines[i..]
.iter()
.position(|l| *l == "ENDSEC" || (*l == "DICTIONARY" || *l == "XRECORD"))
.unwrap_or(lines.len() - i);
found = lines[i..i + record_end]
.windows(2)
.any(|w| w[0].trim() == "291");
break;
}
}
assert!(
found,
"{version:?}: VISUALSTYLE record is missing group code 291"
);
}
}
#[test]
fn handle_less_records_receive_handles_on_read() {
let mut doc = CadDocument::with_version(DxfVersion::AC1012);
doc.add_entity(EntityType::Line(Line::from_coords(0.0, 0.0, 0.0, 1.0, 1.0, 0.0)))
.unwrap();
let mut layer = acadrust::tables::Layer::new("NULL_LAYER");
layer.handle = Handle::NULL;
doc.layers.add(layer).unwrap();
let mut style = acadrust::tables::TextStyle::new("NULL_STYLE");
style.handle = Handle::NULL;
doc.text_styles.add(style).unwrap();
let mut dimstyle = acadrust::tables::DimStyle::new("NULL_DIM");
dimstyle.handle = Handle::NULL;
doc.dim_styles.add(dimstyle).unwrap();
let mut vport = acadrust::tables::VPort::new("NULL_VPORT");
vport.handle = Handle::NULL;
doc.vports.add(vport).unwrap();
let mut appid = acadrust::tables::AppId::new("NULL_APPID");
appid.handle = Handle::NULL;
doc.app_ids.add(appid).unwrap();
let mut xrecord = XRecord::new();
xrecord.handle = Handle::NULL;
doc.objects
.insert(Handle::NULL, ObjectType::XRecord(xrecord));
let mut dict = Dictionary::new();
dict.handle = Handle::NULL;
doc.objects
.insert(Handle::new(0x7001), ObjectType::Dictionary(dict));
doc.resolve_references();
for entry in doc.layers.iter() {
assert!(!entry.handle.is_null(), "layer {:?} still has no handle", entry.name);
}
for entry in doc.text_styles.iter() {
assert!(!entry.handle.is_null(), "text style {:?} still has no handle", entry.name);
}
for entry in doc.dim_styles.iter() {
assert!(!entry.handle.is_null(), "dim style {:?} still has no handle", entry.name);
}
for entry in doc.vports.iter() {
assert!(!entry.handle.is_null(), "vport {:?} still has no handle", entry.name);
}
for entry in doc.app_ids.iter() {
assert!(!entry.handle.is_null(), "appid {:?} still has no handle", entry.name);
}
for (handle, _) in doc.objects.iter() {
assert!(!handle.is_null(), "object still stored under the NULL handle key");
}
let output = String::from_utf8(write_dxf(&doc)).unwrap();
let lines: Vec<&str> = output.split("\r\n").collect();
let mut invalid = Vec::new();
let mut current_record = String::new();
for i in 0..lines.len().saturating_sub(1) {
if lines[i] == " 0" {
current_record = lines.get(i + 1).copied().unwrap_or("?").to_string();
}
let code = lines[i].trim();
if matches!(code, "5" | "105" | "340" | "350" | "360")
&& lines.get(i + 1).map(|v| v.trim()) == Some("0")
{
invalid.push(format!("{} in {}", code, current_record));
}
}
assert!(
invalid.is_empty(),
"invalid handle value 0 written: {:?}",
invalid
);
}
#[test]
fn dictionary_with_default_record_matches_bricscad_form() {
let doc = CadDocument::with_version(DxfVersion::AC1032);
let text = String::from_utf8(write_dxf(&doc)).unwrap();
let lines: Vec<&str> = text.split("\r\n").collect();
for i in 1..lines.len() - 2 {
if lines[i] == "ACDBDICTIONARYWDFLT" && lines[i - 1] == " 0" {
let mut j = i + 1;
while j < lines.len() - 1 && lines[j] != "AcDbDictionary" {
j += 1;
}
assert_eq!(
lines[j + 1].trim(),
"281",
"ACDBDICTIONARYWDFLT must not carry group code 280"
);
}
}
}
#[test]
fn acad_regapp_record_is_written_first() {
for version in [DxfVersion::AC1015, DxfVersion::AC1032] {
let doc = sample_document(version);
let text = String::from_utf8(write_dxf(&doc)).unwrap();
let lines: Vec<&str> = text.split("\r\n").collect();
let mut first_appid_name = None;
for i in 0..lines.len() - 9 {
if lines[i] == " 0" && lines[i + 1] == "APPID" {
for j in i + 2..(i + 20).min(lines.len() - 1) {
if lines[j] == " 2" {
first_appid_name = Some(lines[j + 1].to_string());
break;
}
}
break;
}
}
assert_eq!(
first_appid_name.as_deref(),
Some("ACAD"),
"{version:?}: ACAD must be the first APPID record"
);
}
}
#[test]
fn unrestorable_assoc_objects_are_not_written() {
let mut doc = CadDocument::with_version(DxfVersion::AC1032);
doc.add_entity(EntityType::Line(Line::from_coords(0.0, 0.0, 0.0, 1.0, 1.0, 0.0)))
.unwrap();
let nod_handle = doc.header.named_objects_dict_handle;
let assoc_handle = doc.allocate_handle();
let mut assoc = acadrust::objects::AssociativeObject::new(
"ACDBASSOCVARIABLE",
"AcDbAssocVariable",
);
assoc.handle = assoc_handle;
assoc.owner = nod_handle;
doc.objects
.insert(assoc_handle, ObjectType::Associative(assoc));
if let Some(ObjectType::Dictionary(nod)) = doc.objects.get_mut(&nod_handle) {
nod.add_entry("ACDBASSOC2DCONSTRAINTGROUP", assoc_handle);
}
let text = String::from_utf8(write_dxf(&doc)).unwrap();
assert!(
!text.contains("\r\nACDBASSOCVARIABLE\r\n 5\r\n"),
"unrestorable assoc object must not be written"
);
assert!(
!text.contains(" 3\r\nACDBASSOC2DCONSTRAINTGROUP\r\n"),
"dictionary entries pointing at dropped assoc objects must be filtered"
);
}