mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-31 06:16:51 -06:00
Merge remote-tracking branch 'origin/main' into pr-1693
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
//! Small independent byte fixtures for DXF tag types, text encoding, framing and failures.
|
||||
//! Handwritten examples, not files from a CAD application or generated by our writer.
|
||||
//! Background on the tag model and file structure:
|
||||
//! https://ezdxf.readthedocs.io/en/stable/dxfinternals/dxftags.html
|
||||
//! https://ezdxf.readthedocs.io/en/stable/dxfinternals/filestructure.html
|
||||
//! ASCII spacing is our writer's documented convention; binary fixtures specify actual bytes.
|
||||
|
||||
use cadcraft_dxf::{DxfError, Tag, Value, parse, parse_with_limit, write_ascii_as, write_binary};
|
||||
|
||||
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
|
||||
fn fixtures() -> Vec<(Tag, &'static [u8], &'static [u8])> {
|
||||
vec![
|
||||
(Tag::s(1, " A "), b" 1\r\n A \r\n", b"\x01\0 A \0"),
|
||||
(Tag::s(1, ""), b" 1\r\n\r\n", b"\x01\0\0"),
|
||||
(Tag::s(105, "AB12"), b"105\r\nAB12\r\n", b"\x69\0AB12\0"),
|
||||
(Tag::new(10, Value::Real(-3.25)), b" 10\r\n-3.25\r\n", b"\x0a\0\0\0\0\0\0\0\x0a\xc0"),
|
||||
(Tag::i(70, -32768), b" 70\r\n-32768\r\n", b"\x46\0\0\x80"),
|
||||
(Tag::i(90, -2147483648), b" 90\r\n-2147483648\r\n", b"\x5a\0\0\0\0\x80"),
|
||||
(Tag::i(160, 9223372036854775807), b"160\r\n9223372036854775807\r\n", b"\xa0\0\xff\xff\xff\xff\xff\xff\xff\x7f"),
|
||||
(Tag::new(290, Value::Bool(false)), b"290\r\n0\r\n", b"\x22\x01\0"),
|
||||
(Tag::new(299, Value::Bool(true)), b"299\r\n1\r\n", b"\x2b\x01\x01"),
|
||||
(Tag::new(310, Value::Hex("00FF80".into())), b"310\r\n00FF80\r\n", b"\x36\x01\x03\0\xff\x80"),
|
||||
(Tag::new(1004, Value::Hex(String::new())), b"1004\r\n\r\n", b"\xec\x03\0"),
|
||||
(Tag::i(1070, -2), b"1070\r\n-2\r\n", b"\x2e\x04\xfe\xff"),
|
||||
(Tag::i(1071, 65536), b"1071\r\n65536\r\n", b"\x2f\x04\0\0\x01\0"),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_independently_authored_ascii_and_binary_values() {
|
||||
for (expected, ascii, binary) in fixtures() {
|
||||
assert_eq!(parse(ascii).unwrap(), std::slice::from_ref(&expected), "ASCII code {}", expected.code);
|
||||
let bytes = [SENTINEL, binary].concat();
|
||||
assert_eq!(parse(&bytes).unwrap(), std::slice::from_ref(&expected), "binary code {}", expected.code);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_independently_specified_ascii_and_binary_bytes() {
|
||||
for (tag, ascii, binary) in fixtures() {
|
||||
for utf8 in [false, true] {
|
||||
let tags = std::slice::from_ref(&tag);
|
||||
assert_eq!(write_ascii_as(tags, utf8).as_bytes(), ascii, "ASCII code {}", tag.code);
|
||||
assert_eq!(write_binary(tags, utf8), [SENTINEL, binary].concat(), "binary code {}", tag.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_code_ranges_have_independent_binary_widths() {
|
||||
// Specification ranges and widths, never kind_of(): a shared reader/writer mistake
|
||||
// can round-trip every boundary value while still using the wrong number of bytes.
|
||||
let widths = [
|
||||
(vec![60..=79, 170..=179, 270..=289, 370..=389, 400..=409, 1060..=1070], 2, i64::from(i16::MIN), i64::from(i16::MAX)),
|
||||
(vec![90..=99, 420..=429, 440..=459, 1071..=1071], 4, i64::from(i32::MIN), i64::from(i32::MAX)),
|
||||
(std::iter::once(160..=169).collect(), 8, i64::MIN, i64::MAX),
|
||||
];
|
||||
for (ranges, width, min, max) in widths {
|
||||
for code in ranges.into_iter().flatten() {
|
||||
for value in [min, min + 1, -1, 0, 1, max - 1, max] {
|
||||
// Binary DXF uses a little-endian 16-bit group code and a fixed-width,
|
||||
// little-endian two's-complement integer. Encoding is version-independent.
|
||||
let code_bytes = u16::try_from(code).unwrap().to_le_bytes();
|
||||
let value_bytes = value.to_le_bytes();
|
||||
let expected = [SENTINEL, &code_bytes, &value_bytes[..width]].concat();
|
||||
let tags = [Tag::i(code, value)];
|
||||
for utf8 in [false, true] {
|
||||
assert_eq!(write_binary(&tags, utf8), expected, "code={code} value={value} utf8={utf8}");
|
||||
}
|
||||
assert_eq!(parse(&expected).unwrap(), tags, "code={code} value={value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_encoding_has_independent_wire_expectations() {
|
||||
let tags = [Tag::s(1, "é😀")];
|
||||
let escaped = b"\\U+00E9\\U+D83D\\U+DE00";
|
||||
let utf8 = b"\xc3\xa9\xf0\x9f\x98\x80";
|
||||
for (unicode, text) in [(false, escaped.as_slice()), (true, utf8.as_slice())] {
|
||||
let ascii = [b" 1\r\n".as_slice(), text, b"\r\n"].concat();
|
||||
let binary = [SENTINEL, b"\x01\0", text, b"\0"].concat();
|
||||
assert_eq!(write_ascii_as(&tags, unicode).as_bytes(), ascii, "utf8={unicode}");
|
||||
assert_eq!(write_binary(&tags, unicode), binary, "utf8={unicode}");
|
||||
assert_eq!(parse(&ascii).unwrap(), tags);
|
||||
assert_eq!(parse(&binary).unwrap(), tags);
|
||||
}
|
||||
// Pre-2007 code-page bytes are independent of the writer, which emits Unicode escapes.
|
||||
let ascii = b"0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1015\n9\n$DWGCODEPAGE\n3\nANSI_1252\n0\nENDSEC\n1\n\x80\xe9\n0\nEOF\n";
|
||||
let binary = [
|
||||
SENTINEL,
|
||||
b"\0\0SECTION\0\x02\0HEADER\0\x09\0$ACADVER\0\x01\0AC1015\0\x09\0$DWGCODEPAGE\0\x03\0ANSI_1252\0\0\0ENDSEC\0\x01\0\x80\xe9\0\0\0EOF\0",
|
||||
]
|
||||
.concat();
|
||||
for bytes in [ascii.as_slice(), binary.as_slice()] {
|
||||
let tags = parse(bytes).unwrap();
|
||||
assert_eq!(tags[7], Tag::s(1, "ێ"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_accepts_line_endings_padding_and_no_final_newline() {
|
||||
let expected = [Tag::i(70, -7), Tag::s(1, " text "), Tag::s(0, "EOF")];
|
||||
for newline in ["\n", "\r\n"] {
|
||||
for final_newline in [false, true] {
|
||||
let mut text = [" 70 ", " -7 ", "1", " text ", "0", "EOF"].join(newline);
|
||||
if final_newline {
|
||||
text.push_str(newline);
|
||||
}
|
||||
assert_eq!(parse(text.as_bytes()).unwrap(), expected, "newline={newline:?} final={final_newline}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_and_truncated_values_are_errors_not_panics() {
|
||||
assert!(matches!(parse(b""), Err(DxfError::NotDxf)));
|
||||
assert!(matches!(parse(b"oops\nvalue\n"), Err(DxfError::BadCode { line: 1, .. })));
|
||||
assert!(matches!(parse(b"10\n"), Err(DxfError::Eof)));
|
||||
assert!(matches!(parse(SENTINEL), Err(DxfError::NotDxf)));
|
||||
for (_, _, binary) in fixtures() {
|
||||
// Every cut inside the first record is an error, unlike cuts between complete records:
|
||||
// the tag API deliberately accepts streams without an EOF record.
|
||||
for cut in 1..binary.len() {
|
||||
let bytes = [SENTINEL, &binary[..cut]].concat();
|
||||
assert!(matches!(parse(&bytes), Err(DxfError::Eof)), "binary={binary:?} cut={cut}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_limit_accepts_the_exact_boundary_and_rejects_one_less() {
|
||||
let ascii = b"1\nA\n70\n-7\n0\nEOF\n";
|
||||
let binary = [SENTINEL, b"\x01\0A\0\x46\0\xf9\xff\0\0EOF\0"].concat();
|
||||
for bytes in [ascii.as_slice(), binary.as_slice()] {
|
||||
for limit in 0..3 {
|
||||
assert!(matches!(parse_with_limit(bytes, limit), Err(DxfError::TooLarge { limit: actual, .. }) if actual == limit));
|
||||
}
|
||||
for limit in [3, 4] {
|
||||
assert_eq!(parse_with_limit(bytes, limit).unwrap(), [Tag::s(1, "A"), Tag::i(70, -7), Tag::s(0, "EOF")]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Hex payload conformance is distinct from binary framing capacity: DXF groups 310–319 and
|
||||
//! 1004 specify at most 127 payload bytes per chunk, even though a length byte represents 255.
|
||||
//! Inspect writer bytes without our parser; accept any compliant partition preserving payload,
|
||||
//! code and following-record framing. Oversized binary read fixtures test tolerance only.
|
||||
//! Pending writer regressions extend the chunk-size finding in issue #1025.
|
||||
//! Public DXF Reference, Group Codes in Numerical Order and Binary DXF Files:
|
||||
//! https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-3F0380A5-1C15-464D-BC66-2C5F094BCFB9.htm
|
||||
//! https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-FC1C3C69-DBC2-49E4-893A-000D6538C0FE.htm
|
||||
|
||||
use cadcraft_dxf::{Tag, Value, parse, write_ascii_as, write_binary};
|
||||
|
||||
const SENTINEL: &[u8] = b"AutoCAD Binary DXF\r\n\x1a\0";
|
||||
const FOLLOWING_BINARY: &[u8] = b"\x46\0\xf9\xff"; // group 70, signed i16 -7
|
||||
|
||||
fn payload(length: usize) -> Vec<u8> {
|
||||
(0..length).map(|index| u8::try_from(index * 37 % 256).unwrap()).collect()
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02X}")).collect()
|
||||
}
|
||||
|
||||
fn input(code: i32, bytes: &[u8]) -> [Tag; 2] {
|
||||
[Tag::new(code, Value::Hex(hex(bytes))), Tag::i(70, -7)]
|
||||
}
|
||||
|
||||
fn check_ascii(code: i32, bytes: &[u8], utf8: bool) {
|
||||
let wire = write_ascii_as(&input(code, bytes), utf8);
|
||||
let lines: Vec<_> = wire.lines().collect();
|
||||
let (chunks, following) = lines.split_at(lines.len().checked_sub(2).unwrap());
|
||||
assert_eq!(following[0].trim(), "70");
|
||||
assert_eq!(following[1], "-7");
|
||||
assert!(!chunks.is_empty(), "an empty payload must still have a tag");
|
||||
let (records, remainder) = chunks.as_chunks::<2>();
|
||||
assert!(remainder.is_empty(), "incomplete group/value pair");
|
||||
let context = format!("ASCII code={code} bytes={} utf8={utf8}", bytes.len());
|
||||
let mut back = Vec::new();
|
||||
for record in records {
|
||||
assert_eq!(record[0].trim().parse::<i32>().unwrap(), code, "{context}");
|
||||
let text = record[1];
|
||||
assert!(text.len() <= 254, "{context}: {} hex characters exceed 127 payload bytes", text.len());
|
||||
assert!(text.bytes().all(|b| b.is_ascii_hexdigit()), "{context}: invalid hex");
|
||||
let (pairs, remainder) = text.as_bytes().as_chunks::<2>();
|
||||
assert!(remainder.is_empty(), "{context}: partial byte");
|
||||
back.extend(pairs.iter().map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()));
|
||||
}
|
||||
assert_eq!(back, bytes, "{context}: complete payload and byte order");
|
||||
}
|
||||
|
||||
fn check_binary(code: i32, bytes: &[u8], utf8: bool) {
|
||||
let wire = write_binary(&input(code, bytes), utf8);
|
||||
let mut chunks = wire.strip_prefix(SENTINEL).unwrap().strip_suffix(FOLLOWING_BINARY).unwrap();
|
||||
assert!(!chunks.is_empty(), "an empty payload must still have a tag");
|
||||
let context = format!("binary code={code} bytes={} utf8={utf8}", bytes.len());
|
||||
let mut back = Vec::new();
|
||||
while !chunks.is_empty() {
|
||||
let (&[lo, hi, length], rest) = chunks.split_first_chunk::<3>().unwrap();
|
||||
assert_eq!(i32::from(u16::from_le_bytes([lo, hi])), code, "{context}");
|
||||
assert!(length <= 127, "{context}: {length} bytes exceed the documented group limit");
|
||||
let (chunk, next) = rest.split_at(usize::from(length));
|
||||
back.extend_from_slice(chunk);
|
||||
chunks = next;
|
||||
}
|
||||
assert_eq!(back, bytes, "{context}: complete payload and byte order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conforming_payloads_have_independent_ascii_and_binary_wire_checks() {
|
||||
for code in (310..=319).chain([1004]) {
|
||||
for length in [0, 1, 2, 126, 127] {
|
||||
for utf8 in [false, true] {
|
||||
let bytes = payload(length);
|
||||
check_ascii(code, &bytes, utf8);
|
||||
check_binary(code, &bytes, utf8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_reader_tolerates_the_full_length_byte_domain() {
|
||||
// This is deliberately permissive input, not an assertion that oversized chunks conform
|
||||
// or that the writer should produce them. The following record catches framing mistakes.
|
||||
for code in (310_u16..=319).chain([1004]) {
|
||||
for length in 0..=u8::MAX {
|
||||
let bytes = payload(usize::from(length));
|
||||
let mut wire = SENTINEL.to_vec();
|
||||
wire.extend_from_slice(&code.to_le_bytes());
|
||||
wire.push(length);
|
||||
wire.extend_from_slice(&bytes);
|
||||
wire.extend_from_slice(FOLLOWING_BINARY);
|
||||
assert_eq!(parse(&wire).unwrap(), input(i32::from(code), &bytes), "code={code} length={length}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Confirmed ASCII DXF writer nonconformance: hex payloads above 127 bytes are emitted as one oversized chunk"]
|
||||
fn ascii_writer_splits_large_payloads_into_conforming_chunks() {
|
||||
for code in (310..=319).chain([1004]) {
|
||||
for length in [128, 254, 255, 256, 1024] {
|
||||
for utf8 in [false, true] {
|
||||
check_ascii(code, &payload(length), utf8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Confirmed binary DXF writer nonconformance: 255-byte partitioning exceeds the documented 127-byte group limit"]
|
||||
fn binary_writer_splits_large_payloads_into_conforming_chunks() {
|
||||
for code in (310..=319).chain([1004]) {
|
||||
for length in [128, 254, 255, 256, 1024] {
|
||||
for utf8 in [false, true] {
|
||||
check_binary(code, &payload(length), utf8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Deterministic tag preservation through the public byte API, in both encodings and every
|
||||
//! supported version. Inputs have the group code's type and representable values; lossy
|
||||
//! coercions are not accepted as round-trip equality. Real values must retain their bits.
|
||||
|
||||
use cadcraft_dxf::{Tag, Value, Version, is_binary, parse, write_ascii_as, write_binary};
|
||||
|
||||
fn check(case: &str, body: &[Tag]) {
|
||||
for version in Version::ALL {
|
||||
let mut original = vec![
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "HEADER"),
|
||||
Tag::s(9, "$ACADVER"),
|
||||
Tag::s(1, version.code()),
|
||||
Tag::s(9, "$DWGCODEPAGE"),
|
||||
Tag::s(3, "ANSI_1252"),
|
||||
Tag::s(0, "ENDSEC"),
|
||||
Tag::s(0, "SECTION"),
|
||||
Tag::s(2, "ENTITIES"),
|
||||
];
|
||||
original.extend_from_slice(body);
|
||||
original.extend([Tag::s(0, "ENDSEC"), Tag::s(0, "EOF")]);
|
||||
for binary in [false, true] {
|
||||
let mut current = original.clone();
|
||||
for pass in 1..=2 {
|
||||
let context = format!("{case} · {version} · binary={binary} · pass={pass}");
|
||||
let bytes = if binary { write_binary(¤t, version.utf8()) } else { write_ascii_as(¤t, version.utf8()).into_bytes() };
|
||||
assert_eq!(is_binary(&bytes), binary, "{context}");
|
||||
let back = parse(&bytes).unwrap_or_else(|e| panic!("{context}: {e}"));
|
||||
assert_eq!(back.len(), original.len(), "{context}: tag count");
|
||||
for (index, (expected, actual)) in original.iter().zip(&back).enumerate() {
|
||||
assert_eq!(actual, expected, "{context}: tag[{index}]");
|
||||
if let (Value::Real(a), Value::Real(b)) = (&expected.value, &actual.value) {
|
||||
assert_eq!(b.to_bits(), a.to_bits(), "{context}: tag[{index}] real bits");
|
||||
}
|
||||
}
|
||||
assert_eq!(back, current, "{context}: no additional changes on resave");
|
||||
current = back;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_width_boundaries() {
|
||||
// Explicit reference ranges, not obtained from the codec's kind_of().
|
||||
let widths = [
|
||||
(vec![60..=79, 170..=179, 270..=289, 370..=389, 400..=409, 1060..=1070], i64::from(i16::MIN), i64::from(i16::MAX)),
|
||||
(vec![90..=99, 420..=429, 440..=459, 1071..=1071], i64::from(i32::MIN), i64::from(i32::MAX)),
|
||||
(std::iter::once(160..=169).collect(), i64::MIN, i64::MAX),
|
||||
];
|
||||
for (ranges, min, max) in widths {
|
||||
for value in [min, min + 1, -1, 0, 1, max - 1, max] {
|
||||
let tags: Vec<_> = ranges.iter().cloned().flatten().map(|code| Tag::i(code, value)).collect();
|
||||
check(&format!("integer/{min}..{max}/{value}"), &tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_reals_keep_all_bits() {
|
||||
let values = [
|
||||
0.0,
|
||||
-0.0,
|
||||
f64::from_bits(1),
|
||||
-f64::from_bits(1),
|
||||
f64::MIN_POSITIVE,
|
||||
f64::MIN_POSITIVE.next_down(),
|
||||
0.1,
|
||||
-3.25,
|
||||
1.0_f64.next_down(),
|
||||
1.0,
|
||||
1.0_f64.next_up(),
|
||||
1e-100,
|
||||
-1e100,
|
||||
f64::MAX,
|
||||
f64::MIN,
|
||||
];
|
||||
for value in values {
|
||||
let tags: Vec<_> =
|
||||
[10..=59, 110..=149, 210..=239, 460..=469, 1010..=1059].into_iter().flatten().map(|code| Tag::new(code, Value::Real(value))).collect();
|
||||
check(&format!("real/{:016X}", value.to_bits()), &tags);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn booleans() {
|
||||
for value in [false, true] {
|
||||
let tags: Vec<_> = (290..=299).map(|code| Tag::new(code, Value::Bool(value))).collect();
|
||||
check(&format!("bool/{value}"), &tags);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_preserve_whitespace_unicode_and_literal_escapes() {
|
||||
// No CR/LF or NUL: those are line/string delimiters, not losslessly writable payloads.
|
||||
let values = [
|
||||
("empty", ""),
|
||||
("spaces", " leading and trailing "),
|
||||
("tabs", "\tindented\t"),
|
||||
("unicode", "Café Ø 图号 Україна 😀 e\u{301}"),
|
||||
("literal-escapes", r"\U+0041 \M+183A4 \U+D83D\U+DE00 \\U+005C \P %%c"),
|
||||
("malformed-escapes", r"\U+ \U+ZZZZ \U+D800 \U+DC00 \M+9FFFF"),
|
||||
];
|
||||
for (case, value) in values {
|
||||
let tags: Vec<_> =
|
||||
[0..=9, 100..=100, 102..=102, 105..=105, 300..=309, 320..=369, 410..=419, 430..=439, 470..=479, 480..=481, 1000..=1003, 1005..=1005]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|code| Tag::s(code, value))
|
||||
.collect();
|
||||
check(case, &tags);
|
||||
}
|
||||
for length in [1, 254, 255, 256, 1024] {
|
||||
check(&format!("string/length={length}"), &[Tag::s(1, "x".repeat(length))]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_chunks_keep_bytes_and_order() {
|
||||
// DXF groups 310–319 and 1004 permit 127 payload bytes per chunk. A binary length
|
||||
// byte can represent more, but that is reader tolerance, not writer conformance.
|
||||
// Larger payloads need chunk-size/reassembly checks rather than exact tag equality;
|
||||
// see hex_chunks.rs for the independent wire checks and pending writer regressions.
|
||||
for length in [0, 1, 2, 126, 127] {
|
||||
let hex: String = (0..length).map(|i| format!("{:02X}", (i * 37) % 256)).collect();
|
||||
let tags: Vec<_> = (310..=319).chain([1004]).map(|code| Tag::new(code, Value::Hex(hex.clone()))).collect();
|
||||
check(&format!("hex/bytes={length}"), &tags);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_values_and_repeated_codes_keep_order() {
|
||||
check(
|
||||
"mixed/repeated-codes",
|
||||
&[
|
||||
Tag::s(0, "TEST"),
|
||||
Tag::f(10, 1.25),
|
||||
Tag::s(1, "first"),
|
||||
Tag::f(10, -2.5),
|
||||
Tag::i(70, -7),
|
||||
Tag::s(1, "second"),
|
||||
Tag::new(290, Value::Bool(true)),
|
||||
Tag::new(310, Value::Hex("00FF80".into())),
|
||||
Tag::i(160, i64::MAX),
|
||||
Tag::s(1, ""),
|
||||
],
|
||||
);
|
||||
}
|
||||
+130
-30
@@ -502,6 +502,42 @@ pub fn baseline_start(font: &TextFont, s: &str, p: &TextParams) -> Vec2 {
|
||||
origin + Vec2::new(local.x * mirror.x, local.y * mirror.y).rotate(rot)
|
||||
}
|
||||
|
||||
/// How far below the baseline a bottom-justified text's alignment point lies: a third of the
|
||||
/// height for the stroke font (as AutoCAD places it with SHX fonts), the face's descent for an
|
||||
/// outline font (#2229).
|
||||
fn bottom_depth(font: &TextFont, height: f64) -> f64 {
|
||||
match font {
|
||||
TextFont::Outline(face) => ttf::descent_ratio(face).map_or(height / 3.0, |r| r * height),
|
||||
TextFont::Stroke => height / 3.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The vertical middle of shaped text's ink (its strokes and outlines), above the baseline;
|
||||
/// `None` when it draws nothing. A Middle (M) text is centred there: AutoCAD centres it on the
|
||||
/// string's own extents, descenders included, not at half the height (#2229).
|
||||
fn ink_middle(sh: &Shaped) -> Option<f64> {
|
||||
let ys = sh.strokes.iter().flatten().chain(sh.glyphs.iter().flatten().flatten()).map(|q| q.y).filter(|y| y.is_finite());
|
||||
let (lo, hi) = ys.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), y| (lo.min(y), hi.max(y)));
|
||||
(lo <= hi).then_some((lo + hi) / 2.0)
|
||||
}
|
||||
|
||||
/// The offset of a line's baseline start from its alignment point, for justification `h`/`v`.
|
||||
fn justify_offset(font: &TextFont, sh: &Shaped, height: f64, h: Align, v: VAlign) -> Vec2 {
|
||||
let dx = match h {
|
||||
Align::Left | Align::Aligned | Align::Fit => 0.0,
|
||||
Align::Center | Align::Middle => -sh.width / 2.0,
|
||||
Align::Right => -sh.width,
|
||||
};
|
||||
let dy = match (h, v) {
|
||||
(Align::Middle, _) => -ink_middle(sh).unwrap_or(height / 2.0),
|
||||
(_, VAlign::Baseline) => 0.0,
|
||||
(_, VAlign::Bottom) => bottom_depth(font, height),
|
||||
(_, VAlign::Middle) => -height / 2.0,
|
||||
(_, VAlign::Top) => -height,
|
||||
};
|
||||
Vec2::new(dx, dy)
|
||||
}
|
||||
|
||||
/// How [`place`] puts a line in the world: the point it is placed by, the offset of its baseline
|
||||
/// start from that point (along the text), its rotation and its mirroring (backwards, upside
|
||||
/// down). Aligned and fit text are placed by their insertion point.
|
||||
@@ -512,25 +548,13 @@ fn frame(font: &TextFont, s: &str, p: &TextParams) -> (Vec2, Vec2, f64, Vec2) {
|
||||
let rot = p.align_pt.map_or(rot, |p2| p.insert.angle_to(p2));
|
||||
return (p.insert, Vec2::ZERO, rot, Vec2::new(1.0, 1.0));
|
||||
}
|
||||
let width = shape_line(font, s, height, p.width_factor, p.oblique).width;
|
||||
let sh = shape_line(font, s, height, p.width_factor, p.oblique);
|
||||
let origin = match (p.h, p.v) {
|
||||
(Align::Left, VAlign::Baseline) => p.insert,
|
||||
_ => p.align_pt.unwrap_or(p.insert),
|
||||
};
|
||||
let dx = match p.h {
|
||||
Align::Left | Align::Aligned | Align::Fit => 0.0,
|
||||
Align::Center | Align::Middle => -width / 2.0,
|
||||
Align::Right => -width,
|
||||
};
|
||||
let dy = match (p.h, p.v) {
|
||||
(Align::Middle, _) => -height / 2.0,
|
||||
(_, VAlign::Baseline) => 0.0,
|
||||
(_, VAlign::Bottom) => height / 3.0,
|
||||
(_, VAlign::Middle) => -height / 2.0,
|
||||
(_, VAlign::Top) => -height,
|
||||
};
|
||||
let mirror = Vec2::new(if p.backwards { -1.0 } else { 1.0 }, if p.upside_down { -1.0 } else { 1.0 });
|
||||
(origin, Vec2::new(dx, dy), rot, mirror)
|
||||
(origin, justify_offset(font, &sh, height, p.h, p.v), rot, mirror)
|
||||
}
|
||||
|
||||
/// Place a single-line TEXT in world coordinates with any font. Returns the shaped text and its
|
||||
@@ -563,28 +587,15 @@ pub fn place(font: &TextFont, s: &str, p: &TextParams) -> (Shaped, Bounds2) {
|
||||
(Align::Left, VAlign::Baseline) | (Align::Aligned | Align::Fit, _) => insert,
|
||||
_ => p.align_pt.unwrap_or(insert),
|
||||
};
|
||||
let dx = match p.h {
|
||||
Align::Left | Align::Aligned | Align::Fit => 0.0,
|
||||
Align::Center | Align::Middle => -width / 2.0,
|
||||
Align::Right => -width,
|
||||
};
|
||||
let dy = match (p.h, p.v) {
|
||||
(Align::Middle, _) => -height / 2.0,
|
||||
(_, VAlign::Baseline) => 0.0,
|
||||
(_, VAlign::Bottom) => height / 3.0,
|
||||
(_, VAlign::Middle) => -height / 2.0,
|
||||
(_, VAlign::Top) => -height,
|
||||
};
|
||||
let local = Vec2::new(dx, dy);
|
||||
let local = justify_offset(font, &sh, height, p.h, p.v);
|
||||
let (mx, my) = (if p.backwards { -1.0 } else { 1.0 }, if p.upside_down { -1.0 } else { 1.0 });
|
||||
let xf = |q: Vec2| {
|
||||
let l = q + local;
|
||||
origin + Vec2::new(l.x * mx, l.y * my).rotate(rot)
|
||||
};
|
||||
sh.map(xf);
|
||||
let bb = Bounds2::from_points(
|
||||
[Vec2::new(0.0, -height / 3.0), Vec2::new(width, -height / 3.0), Vec2::new(width, height), Vec2::new(0.0, height)].map(xf),
|
||||
);
|
||||
let depth = bottom_depth(font, height);
|
||||
let bb = Bounds2::from_points([Vec2::new(0.0, -depth), Vec2::new(width, -depth), Vec2::new(width, height), Vec2::new(0.0, height)].map(xf));
|
||||
(sh, bb)
|
||||
}
|
||||
|
||||
@@ -797,3 +808,92 @@ mod ttf_tests {
|
||||
assert!(crate::is_wide('\u{3042}') && crate::is_wide('\u{AC00}') && crate::is_wide('\u{FF21}') && !crate::is_wide('A'));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod justify_tests {
|
||||
use super::*;
|
||||
|
||||
const H: f64 = 2.5;
|
||||
|
||||
/// `s` placed by the alignment point (0, 0) with justification `h`/`v` in `font`.
|
||||
fn placed(font: &TextFont, s: &str, h: Align, v: VAlign) -> (Shaped, Vec2) {
|
||||
let p = TextParams { align_pt: Some(Vec2::ZERO), h, v, ..TextParams::new(Vec2::ZERO, H) };
|
||||
(place(font, s, &p).0, baseline_start(font, s, &p))
|
||||
}
|
||||
|
||||
/// The lowest and highest point of placed text's ink.
|
||||
fn ink_y(sh: &Shaped) -> (f64, f64) {
|
||||
let ys = sh.strokes.iter().flatten().chain(sh.glyphs.iter().flatten().flatten()).map(|q| q.y);
|
||||
ys.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), y| (lo.min(y), hi.max(y)))
|
||||
}
|
||||
|
||||
/// A Middle (M) text is centred on its own ink, descenders included, as AutoCAD centres it,
|
||||
/// not at half the height: lowercase and descenders move the baseline (#2229).
|
||||
#[test]
|
||||
fn a_middle_text_is_centred_on_its_ink() {
|
||||
let font = TextFont::Stroke;
|
||||
let mut baselines = Vec::new();
|
||||
for s in ["ABC 123", "gjpqy", "Tg 1234 ABC", "ace"] {
|
||||
let (sh, start) = placed(&font, s, Align::Middle, VAlign::Baseline);
|
||||
let (lo, hi) = ink_y(&sh);
|
||||
assert!(((lo + hi) / 2.0).abs() < 1e-9, "{s}: ink {lo}..{hi} is centred on the alignment point");
|
||||
// The insertion point a DWG keeps (group 10) is the baseline start, below the ink's
|
||||
// middle by as much as the unplaced text's ink middle is above its baseline.
|
||||
let (lo0, hi0) = ink_y(&shape_line(&font, s, H, 1.0, 0.0));
|
||||
assert!((start.y + (lo0 + hi0) / 2.0).abs() < 1e-9, "{s}: baseline start {start:?}");
|
||||
baselines.push((s, start.y));
|
||||
}
|
||||
// Capitals and digits span the height, so they stay at half of it.
|
||||
assert!((baselines[0].1 + H / 2.0).abs() < 1e-9, "{baselines:?}");
|
||||
// Descenders, or lowercase alone, bring the ink's middle down, so the baseline sits higher.
|
||||
for (s, y) in &baselines[1..] {
|
||||
if *s != "Tg 1234 ABC" {
|
||||
assert!(*y > -H / 2.0 + 0.1, "{s}: {baselines:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text that draws nothing has no ink to centre: it keeps half the height.
|
||||
#[test]
|
||||
fn a_middle_text_without_ink_keeps_half_the_height() {
|
||||
let (_, start) = placed(&TextFont::Stroke, " ", Align::Middle, VAlign::Baseline);
|
||||
assert!((start.y + H / 2.0).abs() < 1e-9, "{start:?}");
|
||||
}
|
||||
|
||||
/// With the stroke font (as with AutoCAD's SHX fonts), a bottom-justified text's baseline is
|
||||
/// a third of the height above its alignment point, and Middle Center still uses half.
|
||||
#[test]
|
||||
fn stroke_font_bottom_and_middle_center_are_unchanged() {
|
||||
let font = TextFont::Stroke;
|
||||
for h in [Align::Left, Align::Center, Align::Right] {
|
||||
let (_, start) = placed(&font, "gjpqy", h, VAlign::Bottom);
|
||||
assert!((start.y - H / 3.0).abs() < 1e-9, "{h:?}: {start:?}");
|
||||
}
|
||||
let (_, start) = placed(&font, "gjpqy", Align::Center, VAlign::Middle);
|
||||
assert!((start.y + H / 2.0).abs() < 1e-9, "MC: {start:?}");
|
||||
}
|
||||
|
||||
/// With a TrueType font, a bottom-justified text's alignment point is at the face's descent,
|
||||
/// where its descenders end, as AutoCAD places it (#2229: Arial's is 0.296 of the height).
|
||||
/// Uses the first of these common fonts that is installed (Windows, macOS and Linux each have
|
||||
/// one); skips without any.
|
||||
#[test]
|
||||
fn a_bottom_justified_truetype_text_sits_on_its_descent() {
|
||||
let found = ["Arial", "Helvetica", "DejaVuSans", "Verdana", "LiberationSans-Regular"].into_iter().find_map(|n| ttf::find(n).map(|f| (n, f)));
|
||||
let Some((name, face)) = found else { return };
|
||||
let ratio = ttf::descent_ratio(&face).unwrap();
|
||||
let font = TextFont::Outline(face);
|
||||
let (sh, start) = placed(&font, "gjpqy", Align::Left, VAlign::Bottom);
|
||||
assert!((start.y - ratio * H).abs() < 1e-9, "{name}: {start:?}");
|
||||
if name == "Arial" {
|
||||
assert!((ratio - 0.296).abs() < 0.01, "Arial's descent over cap height: {ratio}");
|
||||
// The descenders reach the alignment point's line: h/3 left them 0.1 above it.
|
||||
let (lo, _) = ink_y(&sh);
|
||||
assert!(lo.abs() < 0.02 * H, "descender bottom {lo} from the alignment point");
|
||||
}
|
||||
// And Middle centres the ink, as with the stroke font.
|
||||
let (sh, _) = placed(&font, "Tg 1234 ABC", Align::Middle, VAlign::Baseline);
|
||||
let (lo, hi) = ink_y(&sh);
|
||||
assert!(((lo + hi) / 2.0).abs() < 1e-9, "{name}: {lo}..{hi}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -709,6 +709,16 @@ impl OutlinePen for Pen {
|
||||
}
|
||||
}
|
||||
|
||||
/// How far below the baseline a face reaches, as a fraction of the text height (which is the
|
||||
/// face's cap height): its descent over its cap height. A bottom-justified text's alignment
|
||||
/// point is there, as AutoCAD places it (#2229). `None` when the face has no usable descent.
|
||||
pub fn descent_ratio(face: &Face) -> Option<f64> {
|
||||
let f = face.font()?;
|
||||
let descent = -f64::from(f.metrics(Size::new(UPEM), LocationRef::default()).descent);
|
||||
let r = descent / cap_height(&f);
|
||||
(r.is_finite() && r > 0.0 && r < 2.0).then_some(r)
|
||||
}
|
||||
|
||||
/// Cap height of a font in font units at 1000 units per em: the OS/2 value, else the height
|
||||
/// of its "H", else an estimate from the ascent.
|
||||
fn cap_height(f: &FontRef) -> f64 {
|
||||
|
||||
@@ -54,6 +54,7 @@ mod plot_layout_scale;
|
||||
mod plot_model_png_scale;
|
||||
mod plot_styles;
|
||||
mod region_roundtrip;
|
||||
mod roundtrip;
|
||||
mod roundtrip_audit;
|
||||
mod skipped_records;
|
||||
mod sortents;
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Deterministic drawing inputs shared by DXF and DWG tests, independent of either writer.
|
||||
//! Each family isolates a property so a known failure need not disable unrelated coverage.
|
||||
//! Format/version limits and pending failures belong to the callers, not these generators.
|
||||
|
||||
pub(super) mod blocks;
|
||||
pub(super) mod complex;
|
||||
pub(super) mod geometry;
|
||||
pub(super) mod properties;
|
||||
|
||||
use cadcraft_color::{Color, Rgb};
|
||||
use cadcraft_doc::{
|
||||
AnnoScale, Circle, Common, Drawing, EntityKind, HAlign, Layer, Line, Linetype, Lineweight, RawTag, Space, Text, TextStyle, Transparency, VAlign,
|
||||
};
|
||||
use cadcraft_geom::Vec3;
|
||||
|
||||
pub(super) struct Case {
|
||||
pub name: String,
|
||||
pub drawing: Drawing,
|
||||
}
|
||||
|
||||
pub(super) fn drawing() -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
d.layers.push(Layer { name: "Survey_Ä".into(), color: Color::Index(3), ..Layer::default() });
|
||||
d.linetypes.push(Linetype::simple("Dash_test", "Original synthetic dash pattern", &[2.0, -0.75, 0.0, -0.25]));
|
||||
d.text_styles.push(TextStyle { name: "Notes".into(), ..TextStyle::default() });
|
||||
d
|
||||
}
|
||||
|
||||
pub(super) fn text() -> Text {
|
||||
Text {
|
||||
insert: Vec3::new(2.5, -3.75, 4.125),
|
||||
align_pt: None,
|
||||
height: 2.25,
|
||||
value: "CAD Ø".into(),
|
||||
rotation: 0.0,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Standard".into(),
|
||||
halign: HAlign::Left,
|
||||
valign: VAlign::Baseline,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn entity_case(name: &str, common: Common, kind: EntityKind) -> Case {
|
||||
let mut drawing = drawing();
|
||||
drawing.add(&Space::Model, common, kind).unwrap();
|
||||
Case { name: name.into(), drawing }
|
||||
}
|
||||
|
||||
pub(super) fn common_case(name: &str, common: Common) -> Case {
|
||||
let mut drawing = drawing();
|
||||
for kind in [
|
||||
EntityKind::Line(Line { a: Vec3::new(-1.25, 2.5, 3.75), b: Vec3::new(8.125, -13.5, 21.25) }),
|
||||
EntityKind::Circle(Circle { center: Vec3::new(-7.25, 11.5, 4.75), radius: 3.125 }),
|
||||
EntityKind::Text(text()),
|
||||
] {
|
||||
drawing.add(&Space::Model, common.clone(), kind).unwrap();
|
||||
}
|
||||
Case { name: name.into(), drawing }
|
||||
}
|
||||
|
||||
pub(super) fn empty_and_defaults() -> Vec<Case> {
|
||||
vec![Case { name: "empty-model".into(), drawing: drawing() }, common_case("common/default", Common::default())]
|
||||
}
|
||||
|
||||
pub(super) fn geometry() -> Vec<Case> {
|
||||
let mut cases = Vec::new();
|
||||
for (name, a, b) in [
|
||||
("zero-start", Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0)),
|
||||
("zero-end", Vec3::new(-3.0, -2.0, -1.0), Vec3::ZERO),
|
||||
("small", Vec3::new(1e-9, -2e-9, 3e-9), Vec3::new(-4e-9, 5e-9, -6e-9)),
|
||||
("survey", Vec3::new(1e7 + 0.125, -2e7 - 0.25, 123.75), Vec3::new(1e7 + 0.5, -2e7 + 0.75, -42.5)),
|
||||
] {
|
||||
for (direction, start, end) in [("forward", a, b), ("reverse", b, a)] {
|
||||
cases.push(entity_case(&format!("line/{name}/{direction}"), Common::default(), EntityKind::Line(Line { a: start, b: end })));
|
||||
}
|
||||
for radius in [1e-9, 0.125, 3.25, 1e8] {
|
||||
cases.push(entity_case(&format!("circle/{name}/radius={radius}"), Common::default(), EntityKind::Circle(Circle { center: a, radius })));
|
||||
}
|
||||
}
|
||||
cases
|
||||
}
|
||||
|
||||
pub(super) fn layers() -> Vec<Case> {
|
||||
["0", "Survey_Ä"].map(|layer| common_case(&format!("common/layer={layer}"), Common { layer: layer.into(), ..Common::default() })).into()
|
||||
}
|
||||
|
||||
pub(super) fn layer_linetypes() -> Vec<Case> {
|
||||
["0", "Survey_Ä"]
|
||||
.map(|layer| {
|
||||
let mut case = common_case(&format!("common/layer={layer}/inherited-linetype"), Common { layer: layer.into(), ..Common::default() });
|
||||
case.drawing.layer_mut(layer).unwrap().linetype = "Dash_test".into();
|
||||
case
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn colors() -> Vec<Case> {
|
||||
[Color::ByLayer, Color::ByBlock]
|
||||
.into_iter()
|
||||
.chain((1..=255).map(Color::Index))
|
||||
.chain([Rgb(0, 0, 0), Rgb(255, 255, 255), Rgb(255, 0, 0), Rgb(0, 255, 0), Rgb(0, 0, 255), Rgb(17, 93, 241)].map(Color::True))
|
||||
.map(|color| common_case(&format!("common/color={color:?}"), Common { color, ..Common::default() }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn linetypes() -> Vec<Case> {
|
||||
["ByLayer", "ByBlock", "Continuous", "Dash_test"]
|
||||
.map(|linetype| common_case(&format!("common/linetype={linetype}"), Common { linetype: linetype.into(), ..Common::default() }))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn lineweights() -> Vec<Case> {
|
||||
[Lineweight::ByLayer, Lineweight::ByBlock, Lineweight::Default]
|
||||
.into_iter()
|
||||
.chain(Lineweight::STANDARD.map(Lineweight::Mm100))
|
||||
.map(|lineweight| common_case(&format!("common/lineweight={lineweight:?}"), Common { lineweight, ..Common::default() }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn linetype_scales() -> Vec<Case> {
|
||||
[0.125, 1.0, 3.25, 1e6].map(|ltscale| common_case(&format!("common/ltscale={ltscale}"), Common { ltscale, ..Common::default() })).into()
|
||||
}
|
||||
|
||||
pub(super) fn transparencies() -> Vec<Case> {
|
||||
[Transparency::ByLayer, Transparency::ByBlock]
|
||||
.into_iter()
|
||||
.chain((0..=90).map(Transparency::Percent))
|
||||
.map(|transparency| common_case(&format!("common/transparency={transparency:?}"), Common { transparency, ..Common::default() }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn visibility() -> Vec<Case> {
|
||||
[false, true].map(|visible| common_case(&format!("common/visible={visible}"), Common { visible, ..Common::default() })).into()
|
||||
}
|
||||
|
||||
pub(super) fn thicknesses() -> Vec<Case> {
|
||||
[-2.5, 0.0, 3.75].map(|thickness| common_case(&format!("common/thickness={thickness}"), Common { thickness, ..Common::default() })).into()
|
||||
}
|
||||
|
||||
pub(super) fn plot_styles() -> Vec<Case> {
|
||||
["ByLayer", "Review"]
|
||||
.map(|plot_style| common_case(&format!("common/plot_style={plot_style}"), Common { plot_style: plot_style.into(), ..Common::default() }))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn byblock_plot_style() -> Vec<Case> {
|
||||
vec![common_case("common/plot_style=ByBlock", Common { plot_style: "ByBlock".into(), ..Common::default() })]
|
||||
}
|
||||
|
||||
pub(super) fn extrusions() -> Vec<Case> {
|
||||
[Vec3::new(0.0, 0.0, -1.0), Vec3::new(0.0, 1.0, 0.0)]
|
||||
.map(|extrusion| common_case(&format!("common/extrusion={extrusion:?}"), Common { extrusion, ..Common::default() }))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn foreign_xdata() -> Vec<Case> {
|
||||
let xdata = vec![
|
||||
RawTag { code: 1001, value: "ROUNDTRIP_TEST".into() },
|
||||
RawTag { code: 1000, value: "foreign text Ø 😀".into() },
|
||||
RawTag { code: 1070, value: "-17".into() },
|
||||
RawTag { code: 1040, value: "3.25".into() },
|
||||
RawTag { code: 1004, value: "00FF80".into() },
|
||||
];
|
||||
vec![common_case("common/foreign-xdata", Common { xdata, ..Common::default() })]
|
||||
}
|
||||
|
||||
pub(super) fn common_interactions(plot_style: &str) -> Vec<Case> {
|
||||
// Full local product; retain the original DXF case with a named plot style as well as
|
||||
// an inherited-style counterpart, so a named-style loss does not hide other interactions.
|
||||
let mut cases = Vec::new();
|
||||
for layer in ["0", "Survey_Ä"] {
|
||||
for color in [Color::ByLayer, Color::ByBlock, Color::True(Rgb(21, 89, 233))] {
|
||||
for visible in [false, true] {
|
||||
let common = Common {
|
||||
layer: layer.into(),
|
||||
color,
|
||||
visible,
|
||||
linetype: "Dash_test".into(),
|
||||
lineweight: Lineweight::Mm100(53),
|
||||
ltscale: 2.75,
|
||||
transparency: Transparency::Percent(37),
|
||||
thickness: -1.25,
|
||||
plot_style: plot_style.into(),
|
||||
..Common::default()
|
||||
};
|
||||
cases.push(common_case(&format!("common/product/{layer}/{color:?}/visible={visible}/plot_style={plot_style}"), common));
|
||||
}
|
||||
}
|
||||
}
|
||||
cases
|
||||
}
|
||||
|
||||
pub(super) fn text_values() -> Vec<Case> {
|
||||
[("empty", ""), ("whitespace", " a\tb "), ("unicode", "Café 图号 😀 e\u{301}")]
|
||||
.map(|(name, value)| entity_case(&format!("text/value/{name}"), Common::default(), EntityKind::Text(Text { value: value.into(), ..text() })))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn text_literal_escapes() -> Vec<Case> {
|
||||
vec![entity_case("text/value/literal-escapes", Common::default(), EntityKind::Text(Text { value: r"\U+0041 \M+183A4 \P %%c".into(), ..text() }))]
|
||||
}
|
||||
|
||||
pub(super) fn text_heights() -> Vec<Case> {
|
||||
[1e-9, 0.125, 2.25, 1e5]
|
||||
.map(|height| entity_case(&format!("text/height={height}"), Common::default(), EntityKind::Text(Text { height, ..text() })))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn text_rotations() -> Vec<Case> {
|
||||
[0.0, -0.7, std::f64::consts::FRAC_PI_2, 5.9]
|
||||
.map(|rotation| entity_case(&format!("text/rotation={rotation}"), Common::default(), EntityKind::Text(Text { rotation, ..text() })))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn text_widths() -> Vec<Case> {
|
||||
[0.125, 1.0, 2.75]
|
||||
.map(|width_factor| {
|
||||
entity_case(&format!("text/width_factor={width_factor}"), Common::default(), EntityKind::Text(Text { width_factor, ..text() }))
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn text_obliques() -> Vec<Case> {
|
||||
[-0.3, 0.0, 0.4]
|
||||
.map(|oblique| entity_case(&format!("text/oblique={oblique}"), Common::default(), EntityKind::Text(Text { oblique, ..text() })))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn text_styles() -> Vec<Case> {
|
||||
["Standard", "Notes"]
|
||||
.map(|style| entity_case(&format!("text/style={style}"), Common::default(), EntityKind::Text(Text { style: style.into(), ..text() })))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn annotation_scales() -> Vec<Case> {
|
||||
[vec![], vec![AnnoScale::unit()], vec![AnnoScale::unit(), AnnoScale::new("1:50", 1.0, 50.0)]]
|
||||
.map(|scales| {
|
||||
entity_case(&format!("text/anno_scales={scales:?}"), Common { anno_scales: scales, ..Common::default() }, EntityKind::Text(text()))
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn coplanar_text_alignment() -> Vec<Case> {
|
||||
// Construct additional valid single-plane inputs before serialization. Keep the original
|
||||
// differing-elevation cases separately: DXF can store both XYZ points, DWG TEXT has one Z.
|
||||
let mut cases = text_alignment(true);
|
||||
for case in &mut cases {
|
||||
case.name.push_str("/coplanar");
|
||||
let handles: Vec<_> = case.drawing.model.iter().map(|e| e.handle).collect();
|
||||
for handle in handles {
|
||||
case.drawing.model.modify(handle, |e| {
|
||||
if let EntityKind::Text(t) = &mut e.kind {
|
||||
t.align_pt = t.align_pt.map(|p| Vec3::new(p.x, p.y, t.insert.z));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
cases
|
||||
}
|
||||
|
||||
pub(super) fn noncoplanar_text_alignment() -> Vec<Case> {
|
||||
text_alignment(true)
|
||||
.into_iter()
|
||||
.filter(|case| case.drawing.model.iter().any(|e| matches!(&e.kind, EntityKind::Text(t) if t.align_pt.is_some_and(|p| p.z != t.insert.z))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn text_alignment(with_point: bool) -> Vec<Case> {
|
||||
let mut cases = Vec::new();
|
||||
let points = if with_point { vec![Some(Vec3::ZERO), Some(Vec3::new(17.25, -23.5, 6.75))] } else { vec![None] };
|
||||
for halign in [HAlign::Left, HAlign::Center, HAlign::Right, HAlign::Aligned, HAlign::Middle, HAlign::Fit] {
|
||||
for valign in [VAlign::Baseline, VAlign::Bottom, VAlign::Middle, VAlign::Top] {
|
||||
if matches!(halign, HAlign::Aligned | HAlign::Middle | HAlign::Fit) && valign != VAlign::Baseline {
|
||||
continue;
|
||||
}
|
||||
for &align_pt in &points {
|
||||
// The unused second point of left-baseline TEXT has a separate DXF read fixture.
|
||||
if halign == HAlign::Left && valign == VAlign::Baseline && align_pt.is_some() {
|
||||
continue;
|
||||
}
|
||||
let mut t = Text { halign, valign, align_pt, rotation: 0.7, oblique: -0.2, width_factor: 1.25, style: "Notes".into(), ..text() };
|
||||
// Build a consistent baseline before serialization. No writer-derived expected
|
||||
// data: the font layout API determines a valid input; both points compare exactly.
|
||||
if let Some(anchor) = align_pt
|
||||
&& !matches!(halign, HAlign::Aligned | HAlign::Fit)
|
||||
{
|
||||
let (h, v) = cadcraft_render::text_align(halign, valign);
|
||||
let params = cadcraft_fonts::TextParams {
|
||||
insert: t.insert.xy(),
|
||||
align_pt: Some(anchor.xy()),
|
||||
height: t.height,
|
||||
rotation: t.rotation,
|
||||
width_factor: t.width_factor,
|
||||
oblique: t.oblique,
|
||||
h,
|
||||
v,
|
||||
backwards: false,
|
||||
upside_down: false,
|
||||
};
|
||||
t.insert = cadcraft_fonts::baseline_start(&cadcraft_fonts::TextFont::Stroke, &t.value, ¶ms).to3(anchor.z);
|
||||
}
|
||||
cases.push(entity_case(&format!("text/alignment/{halign:?}/{valign:?}/point={align_pt:?}"), Common::default(), EntityKind::Text(t)));
|
||||
}
|
||||
}
|
||||
}
|
||||
cases
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Stored block graphs and references, never expanded geometry. Definitions have independent
|
||||
//! handles; forward, repeated and cyclic references are small bounded synthetic graphs.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Attrib, AttribModes, AttribProps, Block, Common, Entity, EntityKind, Insert, Line, Lineweight, Space, Text};
|
||||
use cadcraft_geom::Vec3;
|
||||
|
||||
use super::{Case, drawing, text};
|
||||
|
||||
pub(crate) fn insert(block: &str) -> Insert {
|
||||
Insert {
|
||||
block: block.into(),
|
||||
insert: Vec3::new(1.25, -2.5, 3.75),
|
||||
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 base(name: &str, reference: Insert) -> Case {
|
||||
let mut drawing = drawing();
|
||||
let mut leaf = Block::new("Z_Leaf");
|
||||
leaf.entities.push(Entity::new(drawing.new_handle(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(2.0, 3.0, 4.0) })));
|
||||
drawing.blocks.insert(leaf.name.clone(), Arc::new(leaf));
|
||||
drawing.add(&Space::Model, Common::default(), EntityKind::Insert(reference)).unwrap();
|
||||
Case { name: name.into(), drawing }
|
||||
}
|
||||
|
||||
pub(crate) fn transformations() -> Vec<Case> {
|
||||
let mut cases = Vec::new();
|
||||
for scale in [Vec3::new(1.0, 1.0, 1.0), Vec3::new(2.0, 0.5, 3.0), Vec3::new(-1.0, 2.0, 1.0)] {
|
||||
for rotation in [0.0, 0.7, 5.9] {
|
||||
cases.push(base(&format!("insert/scale={scale:?}/rotation={rotation}"), Insert { scale, rotation, ..insert("Z_Leaf") }));
|
||||
}
|
||||
}
|
||||
cases
|
||||
}
|
||||
|
||||
pub(crate) fn arrays() -> Vec<Case> {
|
||||
[(1, 3), (4, 1), (3, 2)]
|
||||
.map(|(cols, rows)| {
|
||||
base(&format!("minsert/{cols}x{rows}"), Insert { cols, rows, col_spacing: 5.25, row_spacing: -3.75, rotation: 0.7, ..insert("Z_Leaf") })
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(crate) fn metadata() -> Vec<Case> {
|
||||
let mut case = base("block/metadata", insert("Z_Leaf"));
|
||||
let block = Arc::make_mut(case.drawing.blocks.get_mut("Z_Leaf").unwrap());
|
||||
block.base = Vec3::new(4.0, -3.0, 2.0);
|
||||
block.description = "Synthetic Ø 图".into();
|
||||
block.explodable = false;
|
||||
block.units = 4;
|
||||
vec![case]
|
||||
}
|
||||
|
||||
pub(crate) fn nested(cyclic: bool) -> Vec<Case> {
|
||||
let mut case = base(&format!("block/nested/cyclic={cyclic}"), insert("A_Parent"));
|
||||
let mut parent = Block::new("A_Parent");
|
||||
for x in [2.0, 5.0] {
|
||||
parent
|
||||
.entities
|
||||
.push(Entity::new(case.drawing.new_handle(), EntityKind::Insert(Insert { insert: Vec3::new(x, 0.0, 0.0), ..insert("Z_Leaf") })));
|
||||
}
|
||||
if cyclic {
|
||||
parent.entities.push(Entity::new(case.drawing.new_handle(), EntityKind::Insert(insert("A_Parent"))));
|
||||
}
|
||||
case.drawing.blocks.insert(parent.name.clone(), Arc::new(parent));
|
||||
vec![case]
|
||||
}
|
||||
|
||||
fn attribute(tag: &str) -> Attrib {
|
||||
Attrib {
|
||||
tag: tag.into(),
|
||||
text: Text { insert: Vec3::ZERO, height: 1.0, value: tag.into(), ..text() },
|
||||
invisible: false,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: AttribProps::default(),
|
||||
multiline: None,
|
||||
modes: AttribModes::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn attributes(family: &str) -> Vec<Case> {
|
||||
let mut cases = Vec::new();
|
||||
let variants = match family {
|
||||
"flags" => 0..2,
|
||||
"constant" => 2..4,
|
||||
_ => 0..4,
|
||||
};
|
||||
for variant in variants {
|
||||
let mut definition = attribute("ID");
|
||||
definition.prompt = "Synthetic identifier".into();
|
||||
let mut value = attribute("ID");
|
||||
value.text.value = "Part Ø 图".into();
|
||||
match family {
|
||||
"flags" | "constant" => {
|
||||
definition.invisible = variant & 1 != 0;
|
||||
definition.constant = variant & 2 != 0;
|
||||
value.invisible = definition.invisible;
|
||||
value.constant = definition.constant;
|
||||
}
|
||||
"modes" => {
|
||||
definition.modes.verify = variant & 1 != 0;
|
||||
definition.modes.preset = variant & 2 != 0;
|
||||
}
|
||||
"lock" => {
|
||||
definition.modes.lock_position = variant & 1 != 0;
|
||||
value.modes.lock_position = definition.modes.lock_position;
|
||||
}
|
||||
"overrides" => {
|
||||
value.props = match variant {
|
||||
0 => AttribProps { layer: Some("Survey_Ä".into()), ..Default::default() },
|
||||
1 => AttribProps { color: Some(Color::Index(3)), ..Default::default() },
|
||||
2 => AttribProps { linetype: Some("Dash_test".into()), ..Default::default() },
|
||||
_ => AttribProps { lineweight: Some(Lineweight::Mm100(53)), ..Default::default() },
|
||||
};
|
||||
}
|
||||
_ => panic!("unknown attribute family"),
|
||||
}
|
||||
let mut case = base(&format!("attributes/{family}/{variant}"), Insert { attribs: vec![value, attribute("SECOND")], ..insert("Z_Leaf") });
|
||||
let handle = case.drawing.new_handle();
|
||||
let second = case.drawing.new_handle();
|
||||
let block = Arc::make_mut(case.drawing.blocks.get_mut("Z_Leaf").unwrap());
|
||||
block.entities.push(Entity::new(handle, EntityKind::AttDef(definition)));
|
||||
block.entities.push(Entity::new(second, EntityKind::AttDef(attribute("SECOND"))));
|
||||
cases.push(case);
|
||||
}
|
||||
cases
|
||||
}
|
||||
|
||||
/// Repeated nested instances must retain their own ordered values, including empty text and
|
||||
/// explicit zero overrides. Depth changes the stored owner chain, not attribute identity.
|
||||
pub(crate) fn nested_attributes() -> Vec<Case> {
|
||||
[1, 3]
|
||||
.map(|depth| {
|
||||
let mut case = attributes("flags").remove(0);
|
||||
case.name = format!("block/nested-attributes/depth={depth}");
|
||||
let mut child = "Z_Leaf".to_owned();
|
||||
for level in 0..depth {
|
||||
let mut parent = Block::new(&format!("Parent_{level}"));
|
||||
for instance in 0..2 {
|
||||
let mut value = attribute("ID");
|
||||
value.text.value = if instance == 0 { String::new() } else { format!("level={level}/instance={instance} Ø") };
|
||||
value.props = if instance == 0 {
|
||||
AttribProps::default()
|
||||
} else {
|
||||
AttribProps {
|
||||
layer: Some("Survey_Ä".into()),
|
||||
color: Some(Color::ByBlock),
|
||||
linetype: Some("Continuous".into()),
|
||||
lineweight: Some(Lineweight::Mm100(0)),
|
||||
}
|
||||
};
|
||||
parent.entities.push(Entity::new(
|
||||
case.drawing.new_handle(),
|
||||
EntityKind::Insert(Insert {
|
||||
insert: Vec3::new(f64::from(instance) * 10.0, f64::from(level), 0.0),
|
||||
attribs: vec![value, attribute("SECOND")],
|
||||
..insert(&child)
|
||||
}),
|
||||
));
|
||||
}
|
||||
// Each definition owns its own ATTDEF handles, even when its tags are repeated.
|
||||
for tag in ["ID", "SECOND"] {
|
||||
parent.entities.push(Entity::new(case.drawing.new_handle(), EntityKind::AttDef(attribute(tag))));
|
||||
}
|
||||
child.clone_from(&parent.name);
|
||||
case.drawing.blocks.insert(parent.name.clone(), Arc::new(parent));
|
||||
}
|
||||
let handle = case.drawing.model.iter().next().unwrap().handle;
|
||||
case.drawing.model.modify(handle, |e| {
|
||||
let EntityKind::Insert(i) = &mut e.kind else { panic!("expected INSERT") };
|
||||
i.block = child;
|
||||
});
|
||||
case
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(crate) fn multiline_attributes() -> Vec<Case> {
|
||||
// Keep the original origin witness and distinguish the fallback TEXT's point from MTEXT's.
|
||||
[(Vec3::ZERO, Vec3::ZERO), (Vec3::new(12.0, 7.0, 0.0), Vec3::new(20.0, 8.0, 0.0))]
|
||||
.into_iter()
|
||||
.map(|(text_point, mtext_point)| {
|
||||
let mut case = attributes("flags").remove(0);
|
||||
case.name = format!("attributes/multiline/text-point={text_point:?}");
|
||||
let decorate = |a: &mut Attrib| {
|
||||
a.text.insert = text_point;
|
||||
a.text.value = "first\nsecond".into();
|
||||
a.multiline = Some(cadcraft_doc::MultilineAttrib {
|
||||
mtext: cadcraft_doc::MText {
|
||||
insert: mtext_point,
|
||||
height: 1.0,
|
||||
width: 6.0,
|
||||
contents: "first\\Psecond".into(),
|
||||
..super::geometry::mtext()
|
||||
},
|
||||
read_as: "first\nsecond".into(),
|
||||
});
|
||||
};
|
||||
let handles: Vec<_> = case.drawing.model.iter().map(|e| e.handle).collect();
|
||||
for handle in handles {
|
||||
case.drawing.model.modify(handle, |e| {
|
||||
let EntityKind::Insert(i) = &mut e.kind else { panic!("expected INSERT") };
|
||||
decorate(i.attribs.first_mut().unwrap());
|
||||
});
|
||||
}
|
||||
let block = Arc::make_mut(case.drawing.blocks.get_mut("Z_Leaf").unwrap());
|
||||
let handle = block.entities.iter().find(|e| matches!(&e.kind, EntityKind::AttDef(a) if a.tag == "ID")).unwrap().handle;
|
||||
block.entities.modify(handle, |e| {
|
||||
let EntityKind::AttDef(a) = &mut e.kind else { panic!("expected ATTDEF") };
|
||||
decorate(a);
|
||||
});
|
||||
case
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn unreferenced() -> Vec<Case> {
|
||||
let mut case = base("block/unreferenced", insert("Z_Leaf"));
|
||||
case.drawing.blocks.insert("Empty".into(), Arc::new(Block::new("Empty")));
|
||||
let mut unused = Block::new("Unused");
|
||||
unused.entities.push(Entity::new(case.drawing.new_handle(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(5.0, 6.0, 7.0) })));
|
||||
case.drawing.blocks.insert(unused.name.clone(), Arc::new(unused));
|
||||
vec![case]
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Compound and reference-bearing entities. Geometry and cached values are authored explicitly;
|
||||
//! unsupported versions are selected by callers rather than normalized after reading.
|
||||
|
||||
use super::{Case, entity_case, geometry};
|
||||
use cadcraft_doc::{
|
||||
Block, Common, DimKind, DimMarks, Dimension, Entity, EntityKind, Helix, HelixConstrain, Line, MLeader, MLine, MLineJust, MLineStyle, Region,
|
||||
Tolerance,
|
||||
};
|
||||
use cadcraft_geom::{PolyVertex, Vec2, Vec3};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) fn helices() -> Vec<Case> {
|
||||
[false, true]
|
||||
.into_iter()
|
||||
.flat_map(|ccw| {
|
||||
[HelixConstrain::Height, HelixConstrain::Turns, HelixConstrain::TurnHeight].map(|constrain| {
|
||||
entity_case(
|
||||
&format!("helix/{ccw}/{constrain:?}"),
|
||||
Common::default(),
|
||||
EntityKind::Helix(Helix {
|
||||
center: Vec3::new(1.25, -2.5, 3.75),
|
||||
base_radius: 3.0,
|
||||
top_radius: 1.5,
|
||||
height: -7.5,
|
||||
turns: 2.5,
|
||||
start_angle: 0.0,
|
||||
ccw,
|
||||
constrain,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn mlines() -> Vec<Case> {
|
||||
[false, true]
|
||||
.into_iter()
|
||||
.flat_map(|closed| {
|
||||
[MLineJust::Top, MLineJust::Zero, MLineJust::Bottom].map(|just| {
|
||||
entity_case(
|
||||
&format!("mline/{closed}/{just:?}"),
|
||||
Common::default(),
|
||||
EntityKind::MLine(MLine::new(&MLineStyle::default(), &[Vec2::ZERO, Vec2::new(5.0, 0.0), Vec2::new(5.0, 4.0)], closed, just, 2.5)),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn mleaders() -> Vec<Case> {
|
||||
[false, true]
|
||||
.map(|spline| {
|
||||
entity_case(
|
||||
&format!("mleader/spline={spline}"),
|
||||
Common::default(),
|
||||
EntityKind::MLeader(MLeader {
|
||||
leaders: vec![vec![Vec3::ZERO, Vec3::new(2.0, 3.0, 0.0)], vec![Vec3::new(1.0, -2.0, 0.0)]],
|
||||
landing: Vec3::new(5.0, 4.0, 0.0),
|
||||
dogleg: 0.75,
|
||||
text: Some(cadcraft_doc::MText { insert: Vec3::new(5.9, 4.0, 0.0), rotation: 0.0, ..geometry::mtext() }),
|
||||
style: "Standard".into(),
|
||||
arrow_size: 0.3,
|
||||
spline,
|
||||
block: None,
|
||||
}),
|
||||
)
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(crate) fn regions() -> Vec<Case> {
|
||||
vec![entity_case(
|
||||
"region/bulged-loop",
|
||||
Common::default(),
|
||||
EntityKind::Region(Region::from_loop(vec![
|
||||
PolyVertex::with_bulge(Vec2::ZERO, 0.5),
|
||||
PolyVertex::new(Vec2::new(5.0, 0.0)),
|
||||
PolyVertex::new(Vec2::new(5.0, 4.0)),
|
||||
PolyVertex::new(Vec2::new(0.0, 4.0)),
|
||||
])),
|
||||
)]
|
||||
}
|
||||
|
||||
pub(crate) fn tolerances() -> Vec<Case> {
|
||||
vec![entity_case(
|
||||
"tolerance/frame",
|
||||
Common::default(),
|
||||
EntityKind::Tolerance(Tolerance {
|
||||
insert: Vec3::new(1.25, -2.5, 3.75),
|
||||
dir: Vec3::new(0.0, 1.0, 0.0),
|
||||
style: "ISO-25".into(),
|
||||
text: "0.125%%vA%%vB".into(),
|
||||
}),
|
||||
)]
|
||||
}
|
||||
|
||||
pub(crate) fn dimensions() -> Vec<Case> {
|
||||
[DimKind::Linear { rotation: 0.0 }, DimKind::Aligned]
|
||||
.map(|kind| {
|
||||
let mut case = entity_case(
|
||||
&format!("dimension/{kind:?}"),
|
||||
Common::default(),
|
||||
EntityKind::Dimension(Dimension {
|
||||
kind,
|
||||
defpt: Vec3::new(0.0, 3.0, 0.0),
|
||||
text_mid: Vec3::new(5.0, 4.0, 0.0),
|
||||
p13: Vec3::ZERO,
|
||||
p14: Vec3::new(10.0, 0.0, 0.0),
|
||||
p15: Vec3::ZERO,
|
||||
p16: Vec3::ZERO,
|
||||
text: "<> mm".into(),
|
||||
style: "ISO-25".into(),
|
||||
measurement: 10.0,
|
||||
text_rotation: 0.0,
|
||||
user_text_pos: true,
|
||||
block: Some("*D42".into()),
|
||||
overrides: Default::default(),
|
||||
assoc: Vec::new(),
|
||||
marks: DimMarks::default(),
|
||||
}),
|
||||
);
|
||||
let mut block = Block::new("*D42");
|
||||
block
|
||||
.entities
|
||||
.push(Entity::new(case.drawing.new_handle(), EntityKind::Line(Line { a: Vec3::new(0.0, 3.0, 0.0), b: Vec3::new(10.0, 3.0, 0.0) })));
|
||||
case.drawing.blocks.insert(block.name.clone(), Arc::new(block));
|
||||
case
|
||||
})
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
//! Additional entity families with authored geometry and optional fields. No serializer output
|
||||
//! is used to build an input. New failures are isolated by the format-specific test registrations.
|
||||
|
||||
use cadcraft_doc::{
|
||||
Arc, Common, Ellipse, EntityKind, Face3d, Hatch, HatchLoop, Image, Leader, LwPolyline, MText, MTextColumnType, MTextColumns, Point, Polyline3d,
|
||||
RayLine, Solid, Table, TableCell, Wipeout,
|
||||
};
|
||||
use cadcraft_geom::{PolyVertex, Spline, Vec2, Vec3};
|
||||
|
||||
use super::{Case, entity_case};
|
||||
|
||||
fn case(name: &str, kind: EntityKind) -> Case {
|
||||
entity_case(name, Common::default(), kind)
|
||||
}
|
||||
|
||||
pub(crate) fn arcs() -> Vec<Case> {
|
||||
let mut cases = Vec::new();
|
||||
for radius in [1e-9, 0.125, 3.25, 1e8] {
|
||||
for (start, end) in [(0.0, std::f64::consts::FRAC_PI_2), (0.7, 5.9), (5.9, 0.7)] {
|
||||
cases.push(case(
|
||||
&format!("arc/r={radius}/{start}..{end}"),
|
||||
EntityKind::Arc(Arc { center: Vec3::new(1.25, -2.5, 3.75), radius, start, end }),
|
||||
));
|
||||
}
|
||||
}
|
||||
cases
|
||||
}
|
||||
|
||||
pub(crate) fn ellipses() -> Vec<Case> {
|
||||
[0.125, 0.5, 1.0]
|
||||
.into_iter()
|
||||
.flat_map(|ratio| {
|
||||
[(0.0, std::f64::consts::TAU), (0.7, 5.9), (5.9, 0.7)].map(|(start, end)| {
|
||||
case(
|
||||
&format!("ellipse/{ratio}/{start}..{end}"),
|
||||
EntityKind::Ellipse(Ellipse { center: Vec3::new(1.25, -2.5, 3.75), major: Vec3::new(3.0, 4.0, 0.0), ratio, start, end }),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn polylines(variable_width: bool) -> Vec<Case> {
|
||||
let mut cases = Vec::new();
|
||||
for closed in [false, true] {
|
||||
for plinegen in [false, true] {
|
||||
for bulge in [-1.0, 0.0, 0.5, 1.0] {
|
||||
cases.push(case(
|
||||
&format!("lwpolyline/closed={closed}/plinegen={plinegen}/bulge={bulge}/widths={variable_width}"),
|
||||
EntityKind::LwPolyline(LwPolyline {
|
||||
vertices: vec![
|
||||
PolyVertex {
|
||||
p: Vec2::new(1.25, -2.5),
|
||||
bulge,
|
||||
start_width: if variable_width { 0.125 } else { 0.0 },
|
||||
end_width: if variable_width { 0.75 } else { 0.0 },
|
||||
},
|
||||
PolyVertex::with_bulge(Vec2::new(5.0, 3.0), -bulge),
|
||||
PolyVertex::new(Vec2::new(-2.0, 4.0)),
|
||||
],
|
||||
closed,
|
||||
plinegen,
|
||||
const_width: if variable_width { 0.0 } else { 0.25 },
|
||||
elevation: -3.75,
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
cases
|
||||
}
|
||||
|
||||
pub(crate) fn polylines_3d() -> Vec<Case> {
|
||||
[false, true]
|
||||
.map(|closed| {
|
||||
case(
|
||||
&format!("polyline3d/closed={closed}"),
|
||||
EntityKind::Polyline3d(Polyline3d {
|
||||
points: vec![Vec3::new(1.25, -2.5, 3.75), Vec3::new(5.0, 6.0, -7.0), Vec3::new(-3.0, 4.0, 9.0)],
|
||||
closed,
|
||||
}),
|
||||
)
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(crate) fn splines(rational: bool) -> Vec<Case> {
|
||||
[1, 2, 3]
|
||||
.map(|degree| {
|
||||
let mut spline = Spline::from_control(vec![Vec2::ZERO, Vec2::new(2.0, 3.0), Vec2::new(5.0, -1.0), Vec2::new(8.0, 2.0)], degree);
|
||||
if rational {
|
||||
spline.weights = vec![1.0, 0.5, 2.0, 1.0];
|
||||
}
|
||||
case(&format!("spline/degree={degree}/rational={rational}"), EntityKind::Spline(spline))
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(crate) fn spline_fit(closed: bool) -> Vec<Case> {
|
||||
vec![case(
|
||||
&format!("spline/fit/closed={closed}"),
|
||||
EntityKind::Spline(Spline::from_fit(&[Vec2::ZERO, Vec2::new(2.0, 3.0), Vec2::new(5.0, -1.0), Vec2::new(8.0, 2.0)], closed)),
|
||||
)]
|
||||
}
|
||||
|
||||
pub(crate) fn points_and_rays() -> Vec<Case> {
|
||||
let base = Vec3::new(1.25, -2.5, 3.75);
|
||||
vec![
|
||||
case("point", EntityKind::Point(Point { p: base, angle: 0.0 })),
|
||||
case("ray", EntityKind::Ray(RayLine { base, dir: Vec3::new(0.0, 0.0, 1.0) })),
|
||||
case("xline", EntityKind::XLine(RayLine { base, dir: Vec3::new(0.0, 1.0, 0.0) })),
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn faces() -> Vec<Case> {
|
||||
let corners = [Vec3::new(1.0, 2.0, 3.0), Vec3::new(4.0, 2.0, 3.0), Vec3::new(1.0, 5.0, 3.0), Vec3::new(4.0, 5.0, 3.0)];
|
||||
let mut cases = vec![case("solid", EntityKind::Solid(Solid { corners })), case("trace", EntityKind::Trace(Solid { corners }))];
|
||||
for hidden_edges in 0..16 {
|
||||
cases.push(case(&format!("face3d/hidden={hidden_edges}"), EntityKind::Face3d(Face3d { corners, hidden_edges })));
|
||||
}
|
||||
cases
|
||||
}
|
||||
|
||||
pub(crate) fn mtext() -> MText {
|
||||
MText {
|
||||
insert: Vec3::new(1.25, -2.5, 3.75),
|
||||
height: 2.25,
|
||||
width: 12.5,
|
||||
attach: 1,
|
||||
rotation: 0.0,
|
||||
style: "Notes".into(),
|
||||
contents: "first\\Psecond Ø 图 😀".into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mtext_placement() -> Vec<Case> {
|
||||
(1..=9)
|
||||
.flat_map(|attach| {
|
||||
[0.0, 0.7, 5.9]
|
||||
.map(|rotation| case(&format!("mtext/attach={attach}/rotation={rotation}"), EntityKind::MText(MText { attach, rotation, ..mtext() })))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn mtext_spacing() -> Vec<Case> {
|
||||
[0.25, 1.0, 1.75, 4.0]
|
||||
.into_iter()
|
||||
.flat_map(|line_spacing| {
|
||||
[false, true].map(|line_spacing_exact| {
|
||||
case(
|
||||
&format!("mtext/spacing={line_spacing}/exact={line_spacing_exact}"),
|
||||
EntityKind::MText(MText { line_spacing, line_spacing_exact, ..mtext() }),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn mtext_contents() -> Vec<Case> {
|
||||
["", " a\tb ", "{\\H0.7x;\\S1/2;}\\Pnext", "Unicode Ø 图 😀 e\u{301}"]
|
||||
.map(|contents| case(&format!("mtext/contents={contents:?}"), EntityKind::MText(MText { contents: contents.into(), ..mtext() })))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(crate) fn mtext_carets() -> Vec<Case> {
|
||||
vec![case("mtext/caret-space", EntityKind::MText(MText { contents: "\\pxi-3,l3,t3;1.\tA\\P2.\tB ^ C".into(), ..mtext() }))]
|
||||
}
|
||||
|
||||
pub(crate) fn mtext_long() -> Vec<Case> {
|
||||
[249, 250, 251, 255, 256, 1024]
|
||||
.map(|n| case(&format!("mtext/long={n}"), EntityKind::MText(MText { contents: "Ø😀".repeat(n), ..mtext() })))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(crate) fn mtext_columns() -> Vec<Case> {
|
||||
[
|
||||
MTextColumns { kind: MTextColumnType::Static, count: 3, gutter: 0.5, height: 7.5, ..Default::default() },
|
||||
MTextColumns {
|
||||
kind: MTextColumnType::Dynamic,
|
||||
count: 3,
|
||||
gutter: 0.5,
|
||||
height: 5.0,
|
||||
heights: vec![5.0, 3.0, 4.0],
|
||||
flow_reversed: true,
|
||||
..Default::default()
|
||||
},
|
||||
MTextColumns { kind: MTextColumnType::Dynamic, count: 1, gutter: 0.5, height: 4.0, auto_height: true, ..Default::default() },
|
||||
]
|
||||
.into_iter()
|
||||
.map(|columns| case(&format!("mtext/columns={columns:?}"), EntityKind::MText(MText { columns: Some(columns), ..mtext() })))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn leaders(elevated: bool) -> Vec<Case> {
|
||||
[false, true]
|
||||
.into_iter()
|
||||
.flat_map(|arrow| {
|
||||
[false, true].map(|spline| {
|
||||
case(
|
||||
&format!("leader/arrow={arrow}/spline={spline}/elevated={elevated}"),
|
||||
EntityKind::Leader(Leader {
|
||||
vertices: vec![
|
||||
Vec3::ZERO,
|
||||
Vec3::new(2.0, 3.0, if elevated { 4.0 } else { 0.0 }),
|
||||
Vec3::new(5.0, 6.0, if elevated { 7.0 } else { 0.0 }),
|
||||
],
|
||||
arrow,
|
||||
spline,
|
||||
style: "ISO-25".into(),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn hatches() -> Vec<Case> {
|
||||
let vertices = [Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(10.0, 8.0), Vec2::new(0.0, 8.0)].map(PolyVertex::new).to_vec();
|
||||
(0..=2)
|
||||
.map(|style| {
|
||||
case(
|
||||
&format!("hatch/solid/style={style}"),
|
||||
EntityKind::Hatch(Hatch {
|
||||
pattern: "SOLID".into(),
|
||||
solid: true,
|
||||
loops: vec![HatchLoop { vertices: vertices.clone(), outer: true, sources: Vec::new() }],
|
||||
scale: 1.0,
|
||||
angle: 0.0,
|
||||
associative: false,
|
||||
style,
|
||||
elevation: 3.75,
|
||||
gradient: None,
|
||||
origin: Vec2::ZERO,
|
||||
background: None,
|
||||
pattern_lines: Vec::new(),
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn tables() -> Vec<Case> {
|
||||
vec![case(
|
||||
"table/cells",
|
||||
EntityKind::Table(Table {
|
||||
insert: Vec3::new(1.25, -2.5, 0.0),
|
||||
col_widths: vec![5.0, 7.5],
|
||||
row_heights: vec![2.0, 3.0],
|
||||
cells: vec![
|
||||
vec![TableCell { text: "Ø".into(), ..Default::default() }, TableCell { text: "图".into(), ..Default::default() }],
|
||||
vec![TableCell::default(), TableCell { text: "3.25".into(), ..Default::default() }],
|
||||
],
|
||||
style: "Standard".into(),
|
||||
text_height: 1.25,
|
||||
title: false,
|
||||
header: false,
|
||||
row_styles: Vec::new(),
|
||||
breaks: None,
|
||||
data_link: None,
|
||||
}),
|
||||
)]
|
||||
}
|
||||
|
||||
pub(crate) fn images() -> Vec<Case> {
|
||||
vec![case(
|
||||
"image/reference-only",
|
||||
EntityKind::Image(Image {
|
||||
path: "synthetic-not-loaded.png".into(),
|
||||
name: "Synthetic".into(),
|
||||
insert: Vec3::new(1.0, 2.0, 3.0),
|
||||
u: Vec3::new(0.5, 0.0, 0.0),
|
||||
v: Vec3::new(0.0, 0.25, 0.0),
|
||||
size: Vec2::new(32.0, 16.0),
|
||||
clip: vec![Vec2::new(2.0, 3.0), Vec2::new(20.0, 12.0)],
|
||||
clipping: true,
|
||||
brightness: 37,
|
||||
contrast: 61,
|
||||
fade: 25,
|
||||
..Image::default()
|
||||
}),
|
||||
)]
|
||||
}
|
||||
|
||||
pub(crate) fn wipeouts(explicit_closure: bool) -> Vec<Case> {
|
||||
[false, true]
|
||||
.into_iter()
|
||||
.map(|reversed| {
|
||||
let mut boundary = vec![Vec2::new(1.0, 2.0), Vec2::new(7.0, 3.0), Vec2::new(4.0, 9.0)];
|
||||
if reversed {
|
||||
boundary.reverse();
|
||||
}
|
||||
if explicit_closure {
|
||||
boundary.push(boundary[0]);
|
||||
}
|
||||
case(&format!("wipeout/triangle/explicit-closure={explicit_closure}/reversed={reversed}"), EntityKind::Wipeout(Wipeout { boundary }))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Property, symbol-table and identity inputs. Values are authored before serialization;
|
||||
//! each family can fail independently without suppressing unrelated preservation checks.
|
||||
|
||||
use cadcraft_color::{Color, Rgb};
|
||||
use cadcraft_doc::{Common, DashElement, Entity, EntityKind, HAlign, Handle, Layer, Line, Lineweight, RawTag, Text, TextStyle, Transparency, VAlign};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
|
||||
use super::{Case, common_case, drawing, entity_case, text};
|
||||
|
||||
pub(crate) fn mixed_case_references(kind: &str) -> Vec<Case> {
|
||||
let mut common = Common::default();
|
||||
let mut t = text();
|
||||
match kind {
|
||||
"layer" => common.layer = "sURVEY_Ä".into(),
|
||||
"linetype" => common.linetype = "dASH_TEST".into(),
|
||||
"style" => t.style = "nOTES".into(),
|
||||
_ => panic!("unknown reference family"),
|
||||
}
|
||||
vec![entity_case(&format!("reference/{kind}/mixed-case"), common, EntityKind::Text(t))]
|
||||
}
|
||||
|
||||
fn layer_case(name: &str, layer: Layer) -> Case {
|
||||
let mut case = common_case(name, Common { layer: "Survey_Ä".into(), ..Common::default() });
|
||||
*case.drawing.layer_mut("Survey_Ä").unwrap() = Layer { name: "Survey_Ä".into(), ..layer };
|
||||
case
|
||||
}
|
||||
|
||||
pub(crate) fn layer_flags() -> Vec<Case> {
|
||||
(0..32)
|
||||
.map(|bits| {
|
||||
layer_case(
|
||||
&format!("layer/flags={bits}"),
|
||||
Layer {
|
||||
on: bits & 1 == 0,
|
||||
frozen: bits & 2 != 0,
|
||||
locked: bits & 4 != 0,
|
||||
plot: bits & 8 == 0,
|
||||
vp_freeze_new: bits & 16 != 0,
|
||||
..Layer::default()
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn layer_appearance() -> Vec<Case> {
|
||||
[Color::Index(1), Color::Index(255), Color::True(Rgb(17, 93, 241))]
|
||||
.into_iter()
|
||||
.flat_map(|color| {
|
||||
[0, 37, 90].map(|transparency| {
|
||||
layer_case(
|
||||
&format!("layer/{color:?}/alpha={transparency}"),
|
||||
Layer {
|
||||
color,
|
||||
transparency,
|
||||
linetype: "Dash_test".into(),
|
||||
lineweight: Lineweight::Mm100(53),
|
||||
description: "Synthetic survey Ø 图".into(),
|
||||
..Layer::default()
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn layer_plot_styles() -> Vec<Case> {
|
||||
["Normal", "Review"]
|
||||
.map(|plot_style| layer_case(&format!("layer/plot-style={plot_style}"), Layer { plot_style: plot_style.into(), ..Layer::default() }))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn style_case(name: &str, style: TextStyle) -> Case {
|
||||
let mut case = entity_case(name, Common::default(), EntityKind::Text(Text { style: "Notes".into(), ..text() }));
|
||||
*case.drawing.text_styles.iter_mut().find(|s| s.name == "Notes").unwrap() = TextStyle { name: "Notes".into(), ..style };
|
||||
case
|
||||
}
|
||||
|
||||
pub(crate) fn text_style_metrics() -> Vec<Case> {
|
||||
[0.0, 0.125, 3.5]
|
||||
.into_iter()
|
||||
.flat_map(|height| {
|
||||
[0.5, 1.0, 2.75].map(|width_factor| {
|
||||
style_case(
|
||||
&format!("style/height={height}/width={width_factor}"),
|
||||
TextStyle { height, width_factor, oblique: std::f64::consts::FRAC_PI_4, ..TextStyle::default() },
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn text_style_flags() -> Vec<Case> {
|
||||
(0..16)
|
||||
.map(|bits| {
|
||||
style_case(
|
||||
&format!("style/flags={bits}"),
|
||||
TextStyle {
|
||||
backwards: bits & 1 != 0,
|
||||
upside_down: bits & 2 != 0,
|
||||
vertical: bits & 4 != 0,
|
||||
annotative: bits & 8 != 0,
|
||||
..TextStyle::default()
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn text_style_fonts() -> Vec<Case> {
|
||||
// Names only; no font files are read or required.
|
||||
[("CADCraft Stroke", ""), ("SyntheticSans.ttf", ""), ("synthetic.shx", "synthetic-big.shx")]
|
||||
.map(|(font, big_font)| {
|
||||
style_case(
|
||||
&format!("style/font={font}/big={big_font}"),
|
||||
TextStyle { font: font.into(), big_font: big_font.into(), ..TextStyle::default() },
|
||||
)
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(crate) fn complex_linetypes(shape: bool) -> Vec<Case> {
|
||||
[false, true]
|
||||
.map(|absolute| {
|
||||
let mut case =
|
||||
common_case(&format!("linetype/shape={shape}/absolute={absolute}"), Common { linetype: "Dash_test".into(), ..Common::default() });
|
||||
if shape {
|
||||
case.drawing.text_styles.iter_mut().find(|s| s.name == "Notes").unwrap().font = "synthetic.shx".into();
|
||||
}
|
||||
let l = case.drawing.linetypes.iter_mut().find(|l| l.name == "Dash_test").unwrap();
|
||||
l.pattern.push(DashElement {
|
||||
text: (!shape).then(|| "GAS Ø".into()),
|
||||
shape: shape.then_some(7),
|
||||
style: Some("Notes".into()),
|
||||
scale: 0.75,
|
||||
rotation: std::f64::consts::FRAC_PI_2,
|
||||
offset: Vec2::new(0.125, -0.25),
|
||||
absolute,
|
||||
..DashElement::dash(-1.0)
|
||||
});
|
||||
case
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
fn xdata_case(name: &str, tags: Vec<(i32, String)>) -> Case {
|
||||
common_case(name, Common { xdata: tags.into_iter().map(|(code, value)| RawTag { code, value }).collect(), ..Common::default() })
|
||||
}
|
||||
|
||||
pub(crate) fn xdata_lists() -> Vec<Case> {
|
||||
vec![xdata_case(
|
||||
"xdata/nested-and-multiple-apps",
|
||||
[
|
||||
(1001, "ROUNDTRIP_A"),
|
||||
(1002, "{"),
|
||||
(1000, "outer"),
|
||||
(1002, "{"),
|
||||
(1070, "-32768"),
|
||||
(1002, "}"),
|
||||
(1071, "2147483647"),
|
||||
(1002, "}"),
|
||||
(1001, "ROUNDTRIP_B"),
|
||||
(1000, ""),
|
||||
(1000, "repeated Ø"),
|
||||
(1004, "00FF80"),
|
||||
(1041, "0.125"),
|
||||
(1042, "2.75"),
|
||||
]
|
||||
.map(|(c, v)| (c, v.into()))
|
||||
.into(),
|
||||
)]
|
||||
}
|
||||
|
||||
pub(crate) fn xdata_points() -> Vec<Case> {
|
||||
(1010..=1013)
|
||||
.map(|code| {
|
||||
xdata_case(
|
||||
&format!("xdata/point={code}"),
|
||||
vec![(1001, "ROUNDTRIP_POINTS".into()), (code, "1.25".into()), (code + 10, "-2.5".into()), (code + 20, "3.75".into())],
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn xdata_handles() -> Vec<Case> {
|
||||
let mut case = common_case("xdata/handle-reference", Common::default());
|
||||
let handles: Vec<_> = case.drawing.model.iter().map(|e| e.handle).collect();
|
||||
for &handle in &handles {
|
||||
case.drawing.model.modify(handle, |e| {
|
||||
e.common.xdata =
|
||||
vec![RawTag { code: 1001, value: "ROUNDTRIP_HANDLES".into() }, RawTag { code: 1005, value: handles.last().unwrap().hex() }]
|
||||
});
|
||||
}
|
||||
vec![case]
|
||||
}
|
||||
|
||||
pub(crate) fn handles_and_order() -> Vec<Case> {
|
||||
sparse_handles(&[0x1_0000, 0x1_0000_0000])
|
||||
}
|
||||
|
||||
pub(crate) fn handles_near_integer_limits() -> Vec<Case> {
|
||||
// Cross f64's exact-integer limit and signed i64's limit; leave room below u64::MAX
|
||||
// for generated table/object handles rather than testing allocator exhaustion here.
|
||||
sparse_handles(&[(1 << 53) - 0x80, (1 << 63) - 0x80, u64::MAX - 0x1_0000])
|
||||
}
|
||||
|
||||
pub(crate) fn exhausted_handles() -> Case {
|
||||
// The seed and writer's additional 0x1000 reservation leave no allocation room.
|
||||
sparse_handles(&[u64::MAX - 0x2000]).remove(0)
|
||||
}
|
||||
|
||||
fn sparse_handles(bases: &[u64]) -> Vec<Case> {
|
||||
bases
|
||||
.iter()
|
||||
.map(|&base| {
|
||||
let mut drawing = drawing();
|
||||
for index in [5, 1, 9, 3, 2, 8, 4, 7, 6] {
|
||||
let kind = if index % 2 == 0 {
|
||||
EntityKind::Text(Text { value: format!("entity-{index}"), ..text() })
|
||||
} else {
|
||||
EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(index as f64, 2.0, 3.0) })
|
||||
};
|
||||
drawing.model.push(Entity::new(Handle(base + index * 17), kind));
|
||||
}
|
||||
drawing.handseed = base + 0x1000;
|
||||
Case { name: format!("handles/sparse/base={base:X}"), drawing }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn degenerate_geometry() -> Vec<Case> {
|
||||
[Vec3::ZERO, Vec3::new(1e9, -1e9, 1e-9)]
|
||||
.map(|p| entity_case(&format!("line/coincident={p:?}"), Common::default(), EntityKind::Line(Line { a: p, b: p })))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(crate) fn text_interactions() -> Vec<Case> {
|
||||
let mut cases = Vec::new();
|
||||
for rotation in [-0.7, 0.0, 5.9] {
|
||||
for width_factor in [0.125, 2.75] {
|
||||
for oblique in [-0.3, 0.4] {
|
||||
cases.push(entity_case(
|
||||
&format!("text/product/{rotation}/{width_factor}/{oblique}"),
|
||||
Common::default(),
|
||||
EntityKind::Text(Text {
|
||||
rotation,
|
||||
width_factor,
|
||||
oblique,
|
||||
height: 0.125,
|
||||
style: "Notes".into(),
|
||||
value: " Ø 图 😀 ".into(),
|
||||
..text()
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
cases
|
||||
}
|
||||
|
||||
/// Four independently overridden properties on a deliberately non-default layer. The 16 masks
|
||||
/// cover inheritance/override pairs without multiplying by every possible property value.
|
||||
pub(crate) fn layer_inheritance_overrides() -> Vec<Case> {
|
||||
(0..16)
|
||||
.map(|mask| {
|
||||
let common = Common {
|
||||
layer: "Survey_Ä".into(),
|
||||
color: if mask & 1 == 0 { Color::ByLayer } else { Color::True(Rgb(0, 0, 0)) },
|
||||
transparency: if mask & 2 == 0 { Transparency::ByLayer } else { Transparency::Percent(0) },
|
||||
lineweight: if mask & 4 == 0 { Lineweight::ByLayer } else { Lineweight::Mm100(0) },
|
||||
linetype: if mask & 8 == 0 { "ByLayer" } else { "Continuous" }.into(),
|
||||
..Common::default()
|
||||
};
|
||||
let mut case = common_case(&format!("layer/inheritance-overrides={mask:04b}"), common);
|
||||
*case.drawing.layer_mut("Survey_Ä").unwrap() = Layer {
|
||||
name: "Survey_Ä".into(),
|
||||
color: Color::Index(3),
|
||||
transparency: 67,
|
||||
lineweight: Lineweight::Mm100(53),
|
||||
linetype: "Dash_test".into(),
|
||||
..Layer::default()
|
||||
};
|
||||
case
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Mirrored styles affect baseline placement; their metrics must not overwrite the entity's
|
||||
/// own height/width/oblique. Font layout constructs consistent inputs, never expected output.
|
||||
pub(crate) fn alignment_style_interactions() -> Vec<Case> {
|
||||
let mut cases = Vec::new();
|
||||
for (halign, valign) in [
|
||||
(HAlign::Left, VAlign::Top),
|
||||
(HAlign::Center, VAlign::Middle),
|
||||
(HAlign::Right, VAlign::Baseline),
|
||||
(HAlign::Aligned, VAlign::Baseline),
|
||||
(HAlign::Fit, VAlign::Baseline),
|
||||
] {
|
||||
for flags in 0..4 {
|
||||
let style = TextStyle {
|
||||
name: "Notes".into(),
|
||||
height: 7.0,
|
||||
width_factor: 2.0,
|
||||
oblique: 0.25,
|
||||
backwards: flags & 1 != 0,
|
||||
upside_down: flags & 2 != 0,
|
||||
..TextStyle::default()
|
||||
};
|
||||
let mut t = Text {
|
||||
insert: Vec3::new(-5.0, 3.0, 0.0),
|
||||
align_pt: Some(Vec3::ZERO),
|
||||
style: style.name.clone(),
|
||||
halign,
|
||||
valign,
|
||||
height: 1.5,
|
||||
width_factor: 0.75,
|
||||
oblique: -0.125,
|
||||
rotation: 0.7,
|
||||
..text()
|
||||
};
|
||||
if !matches!(halign, HAlign::Aligned | HAlign::Fit) {
|
||||
let (h, v) = cadcraft_render::text_align(halign, valign);
|
||||
let params = cadcraft_fonts::TextParams {
|
||||
insert: t.insert.xy(),
|
||||
align_pt: Some(Vec2::ZERO),
|
||||
height: t.height,
|
||||
rotation: t.rotation,
|
||||
width_factor: t.width_factor,
|
||||
oblique: t.oblique,
|
||||
h,
|
||||
v,
|
||||
backwards: style.backwards,
|
||||
upside_down: style.upside_down,
|
||||
};
|
||||
t.insert = cadcraft_fonts::baseline_start(&cadcraft_fonts::TextFont::Stroke, &t.value, ¶ms).to3(0.0);
|
||||
}
|
||||
let mut case = entity_case(&format!("text/alignment-style/{halign:?}/{valign:?}/flags={flags}"), Common::default(), EntityKind::Text(t));
|
||||
*case.drawing.text_styles.iter_mut().find(|s| s.name == "Notes").unwrap() = style;
|
||||
cases.push(case);
|
||||
}
|
||||
}
|
||||
cases
|
||||
}
|
||||
|
||||
pub(crate) fn long_text() -> Vec<Case> {
|
||||
[254, 255, 256, 1024]
|
||||
.map(|length| {
|
||||
entity_case(&format!("text/length={length}"), Common::default(), EntityKind::Text(Text { value: "Ø".repeat(length), ..text() }))
|
||||
})
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Drawing-collection contracts beyond model space. Assertions cover explicitly authored data,
|
||||
//! not generated timestamps/extents, allocation seeds or extra implementation-owned objects.
|
||||
//! Named tables are compared by identity; paper entities retain order and handles. These are
|
||||
//! focused collection contracts, not a claim that every Drawing/session field is persistent.
|
||||
|
||||
use super::{
|
||||
cases::{self, Case},
|
||||
compare,
|
||||
};
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{
|
||||
Common, Drawing, EntityKind, Group, HVal, Layer, LayerState, Layout, Line, Linetype, NamedView, Space, TextStyle, Ucs, Viewport, VportConfig,
|
||||
VportTile,
|
||||
};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
|
||||
pub(super) fn foreign_records() -> Case {
|
||||
use cadcraft_doc::{Handle, RawTag, foreign::ForeignObject};
|
||||
let mut drawing = cases::drawing();
|
||||
let foreign = std::sync::Arc::make_mut(&mut drawing.foreign);
|
||||
foreign.dictionary.push(("SYNTHETIC_PAYLOAD".into(), Handle(0xA000)));
|
||||
foreign.targets.insert(Handle(0xA001), ("NOD".into(), String::new()));
|
||||
foreign.objects.push(ForeignObject {
|
||||
handle: Handle(0xA000),
|
||||
kind: "XRECORD".into(),
|
||||
tags: [
|
||||
(5, "A000"),
|
||||
(330, "A001"),
|
||||
(100, "AcDbXrecord"),
|
||||
(280, "1"),
|
||||
(1, "Original Ø payload"),
|
||||
(90, "-7"),
|
||||
(310, "00FF80"),
|
||||
(10, "1.25"),
|
||||
(20, "-2.5"),
|
||||
(30, "3.75"),
|
||||
]
|
||||
.map(|(code, value)| RawTag { code, value: value.into() })
|
||||
.into(),
|
||||
});
|
||||
drawing.handseed = 0xA100;
|
||||
Case { name: "drawing/foreign-xrecord".into(), drawing }
|
||||
}
|
||||
|
||||
pub(super) fn assert_foreign_records(a: &Drawing, b: &Drawing, context: &str) {
|
||||
// Rereads may add implementation-owned dictionaries; this case owns one explicit key.
|
||||
let key = "SYNTHETIC_PAYLOAD";
|
||||
let (_, handle) = a.foreign.dictionary.iter().find(|(name, _)| name == key).expect("authored dictionary entry");
|
||||
let (_, target) = b.foreign.dictionary.iter().find(|(name, _)| name == key).unwrap_or_else(|| panic!("{context}.dictionary[{key}]: missing key"));
|
||||
let source = a.foreign.objects.iter().find(|o| o.handle == *handle).unwrap();
|
||||
let back = b.foreign.objects.iter().find(|o| o.handle == *target).expect("dictionary target must resolve");
|
||||
assert_eq!(back.kind, source.kind, "{context}.object.kind");
|
||||
let owner = back.tags.iter().find(|t| t.code == 330).and_then(|t| cadcraft_doc::Handle::parse_hex(&t.value)).expect("XRECORD owner");
|
||||
assert_eq!(b.foreign.targets.get(&owner), Some(&("NOD".into(), String::new())), "{context}.object.owner");
|
||||
// Foreign's documented contract reallocates object handles and remaps references.
|
||||
// Identity here is the NOD key and its resolved target, not the numeric allocation.
|
||||
// Preserve every payload tag (including its order) after the subclass marker.
|
||||
fn payload(tags: &[cadcraft_doc::RawTag]) -> &[cadcraft_doc::RawTag] {
|
||||
let start =
|
||||
tags.iter().position(|t| t.code == 100 && t.value == "AcDbXrecord").unwrap_or_else(|| panic!("missing XRECORD subclass: {tags:?}"));
|
||||
&tags[start + 1..]
|
||||
}
|
||||
assert_eq!(payload(&back.tags), payload(&source.tags), "{context}.object.payload");
|
||||
}
|
||||
|
||||
const HEADER_KEYS: &[&str] = &["LTSCALE", "CELTSCALE", "LUPREC", "AUPREC", "INSBASE", "TEXTSIZE", "CLAYER", "TEXTSTYLE"];
|
||||
|
||||
pub(super) fn headers() -> Case {
|
||||
let mut drawing = cases::drawing();
|
||||
drawing.header.set_f64("LTSCALE", 2.75);
|
||||
drawing.header.set_f64("CELTSCALE", 0.125);
|
||||
drawing.header.set_i64("LUPREC", 7);
|
||||
drawing.header.set_i64("AUPREC", 3);
|
||||
drawing.header.set("INSBASE", HVal::Point(Vec3::new(1.25, -2.5, 3.75)));
|
||||
drawing.header.set_f64("TEXTSIZE", 3.25);
|
||||
|
||||
Case { name: "drawing/header-variables".into(), drawing }
|
||||
}
|
||||
|
||||
pub(super) fn named_header_reference(key: &str) -> Case {
|
||||
let mut case = headers();
|
||||
case.name = format!("drawing/header-reference/{key}");
|
||||
case.drawing.header.set_str(
|
||||
key,
|
||||
match key {
|
||||
"CLAYER" => "Survey_Ä",
|
||||
"TEXTSTYLE" => "Notes",
|
||||
_ => panic!("unknown header reference"),
|
||||
},
|
||||
);
|
||||
case
|
||||
}
|
||||
|
||||
pub(super) fn assert_headers(a: &Drawing, b: &Drawing, context: &str) {
|
||||
for key in HEADER_KEYS {
|
||||
assert_eq!(b.header.get(key), a.header.get(key), "{context}.header.{key}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn symbol_tables() -> Case {
|
||||
let mut drawing = cases::drawing();
|
||||
drawing.layers.push(Layer {
|
||||
name: "UnusedLayer".into(),
|
||||
color: Color::Index(5),
|
||||
locked: true,
|
||||
description: "Unused Ø".into(),
|
||||
..Layer::default()
|
||||
});
|
||||
drawing.linetypes.push(Linetype::simple("UnusedDash", "Unused synthetic", &[3.0, -0.5, 0.0, -0.25]));
|
||||
drawing.text_styles.push(TextStyle { name: "UnusedStyle".into(), font: "SyntheticSans.ttf".into(), width_factor: 1.25, ..TextStyle::default() });
|
||||
Case { name: "drawing/unreferenced-tables".into(), drawing }
|
||||
}
|
||||
|
||||
pub(super) fn assert_symbol_tables(a: &Drawing, b: &Drawing, context: &str) {
|
||||
for layer in &a.layers {
|
||||
assert_eq!(b.layer(&layer.name), Some(layer), "{context}.layer[{}]", layer.name);
|
||||
}
|
||||
for linetype in &a.linetypes {
|
||||
assert_eq!(b.linetype(&linetype.name), Some(linetype), "{context}.linetype[{}]", linetype.name);
|
||||
}
|
||||
for style in &a.text_styles {
|
||||
assert_eq!(b.text_style(&style.name), Some(style), "{context}.style[{}]", style.name);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn views_and_ucs() -> Case {
|
||||
let mut drawing = cases::drawing();
|
||||
drawing.views.push(NamedView { name: "SurveyView".into(), center: Vec2::new(1.25, -2.5), height: 25.0, width: 40.0, ..NamedView::default() });
|
||||
drawing.ucss.push(Ucs {
|
||||
name: "RotatedUCS".into(),
|
||||
origin: Vec3::new(3.0, 4.0, 5.0),
|
||||
x_axis: Vec3::new(0.0, 1.0, 0.0),
|
||||
y_axis: Vec3::new(-1.0, 0.0, 0.0),
|
||||
});
|
||||
Case { name: "drawing/views-and-ucs".into(), drawing }
|
||||
}
|
||||
|
||||
pub(super) fn assert_views_and_ucs(a: &Drawing, b: &Drawing, context: &str) {
|
||||
assert_eq!(b.views, a.views, "{context}.views");
|
||||
assert_eq!(b.ucss, a.ucss, "{context}.ucss");
|
||||
}
|
||||
|
||||
pub(super) fn vports() -> Case {
|
||||
let mut drawing = cases::drawing();
|
||||
drawing.vports.push(VportConfig {
|
||||
name: "Split".into(),
|
||||
tiles: vec![
|
||||
VportTile { min: Vec2::ZERO, max: Vec2::new(0.5, 1.0), center: Vec2::new(2.0, 3.0), height: 20.0, aspect: 0.75 },
|
||||
VportTile { min: Vec2::new(0.5, 0.0), max: Vec2::new(1.0, 1.0), center: Vec2::new(7.0, 8.0), height: 30.0, aspect: 0.75 },
|
||||
],
|
||||
});
|
||||
Case { name: "drawing/tiled-viewports".into(), drawing }
|
||||
}
|
||||
|
||||
pub(super) fn assert_vports(a: &Drawing, b: &Drawing, context: &str) {
|
||||
for viewport in &a.vports {
|
||||
assert_eq!(b.vports.iter().find(|v| v.name == viewport.name), Some(viewport), "{context}.vport[{}]", viewport.name);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn groups() -> Case {
|
||||
let mut case = cases::common_case("drawing/groups", Common::default());
|
||||
let handles: Vec<_> = case.drawing.model.iter().map(|e| e.handle).collect();
|
||||
case.drawing.groups.push(Group {
|
||||
name: "Synthetic".into(),
|
||||
description: "Ordered overlapping membership Ø".into(),
|
||||
selectable: true,
|
||||
members: vec![handles[2], handles[0]],
|
||||
});
|
||||
case.drawing.groups.push(Group { name: "Other".into(), description: String::new(), selectable: false, members: vec![handles[1], handles[2]] });
|
||||
case
|
||||
}
|
||||
|
||||
pub(super) fn assert_groups(a: &Drawing, b: &Drawing, context: &str) {
|
||||
assert_eq!(b.groups.len(), a.groups.len(), "{context}.groups.len");
|
||||
for group in &a.groups {
|
||||
assert_eq!(b.groups.iter().find(|g| g.name == group.name), Some(group), "{context}.group[{}]", group.name);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn layer_states() -> Case {
|
||||
let mut drawing = cases::drawing();
|
||||
let mut layers = drawing.layers.clone();
|
||||
for layer in &mut layers {
|
||||
layer.on = false;
|
||||
layer.locked = true;
|
||||
}
|
||||
drawing.layer_states.push(LayerState { name: "Saved".into(), layers });
|
||||
Case { name: "drawing/layer-state".into(), drawing }
|
||||
}
|
||||
|
||||
pub(super) fn assert_layer_states(a: &Drawing, b: &Drawing, context: &str) {
|
||||
assert_eq!(b.layer_states, a.layer_states, "{context}.layer_states");
|
||||
}
|
||||
|
||||
pub(super) fn layouts() -> Case {
|
||||
let mut drawing = cases::drawing();
|
||||
drawing.layouts = vec![Layout::new("Sheet_A", 1), Layout::new("Sheet_B", 2)];
|
||||
for (index, layout) in drawing.layouts.iter_mut().enumerate() {
|
||||
layout.page.paper_mm = true;
|
||||
// PageSetup stores portrait dimensions; landscape is a separate orientation flag.
|
||||
layout.page.width_mm = 297.0;
|
||||
layout.page.height_mm = 420.0;
|
||||
layout.page.scale = 0.5;
|
||||
layout.page.plot_area = "window".into();
|
||||
layout.page.plot_window = [1.0, 2.0, 201.0, 142.0];
|
||||
layout.view = Some((Vec2::new(10.0, 20.0), 100.0 + index as f64));
|
||||
}
|
||||
drawing.active_layout = Some("Sheet_B".into());
|
||||
for name in ["Sheet_A", "Sheet_B"] {
|
||||
drawing.add(&Space::Paper(name.into()), Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(10.0, 20.0, 0.0) })).unwrap();
|
||||
drawing
|
||||
.add(
|
||||
&Space::Paper(name.into()),
|
||||
Common::default(),
|
||||
EntityKind::Viewport(Viewport {
|
||||
center: Vec3::new(50.0, 60.0, 0.0),
|
||||
width: 80.0,
|
||||
height: 50.0,
|
||||
view_center: Vec2::new(5.0, 7.0),
|
||||
view_height: 25.0,
|
||||
id: 2,
|
||||
locked: true,
|
||||
frozen_layers: vec!["Survey_Ä".into()],
|
||||
..Viewport::default()
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
Case { name: "drawing/layouts-and-paper-entities".into(), drawing }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection_assertions_reject_lost_authored_data() {
|
||||
type Check = fn(&Drawing, &Drawing, &str);
|
||||
type Mutate = fn(&mut Drawing);
|
||||
let cases: [(Case, Check, Mutate); 6] = [
|
||||
(headers(), assert_headers, |d| d.header.set_f64("TEXTSIZE", 9.0)),
|
||||
(symbol_tables(), assert_symbol_tables, |d| d.layers.retain(|l| l.name != "UnusedLayer")),
|
||||
(views_and_ucs(), assert_views_and_ucs, |d| d.ucss.clear()),
|
||||
(groups(), assert_groups, |d| d.groups.first_mut().unwrap().members.reverse()),
|
||||
(layouts(), assert_layouts, |d| d.layouts.first_mut().unwrap().page.scale = 2.0),
|
||||
(foreign_records(), assert_foreign_records, |d| std::sync::Arc::make_mut(&mut d.foreign).objects.clear()),
|
||||
];
|
||||
for (case, verify, mutate) in cases {
|
||||
verify(&case.drawing, &case.drawing, &case.name);
|
||||
let mut changed = case.drawing.clone();
|
||||
mutate(&mut changed);
|
||||
assert!(std::panic::catch_unwind(|| verify(&case.drawing, &changed, &case.name)).is_err(), "{}: lost data must fail", case.name);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn assert_layouts(a: &Drawing, b: &Drawing, context: &str) {
|
||||
assert_eq!(b.layouts.len(), a.layouts.len(), "{context}.layouts.len");
|
||||
assert_eq!(b.active_layout, a.active_layout, "{context}.active_layout");
|
||||
for layout in &a.layouts {
|
||||
let back = b.layouts.iter().find(|l| l.name == layout.name).expect("layout must survive");
|
||||
assert_eq!(back.tab_order, layout.tab_order, "{context}.{}.tab_order", layout.name);
|
||||
assert_eq!(back.page, layout.page, "{context}.{}.page", layout.name);
|
||||
assert_eq!(back.view, layout.view, "{context}.{}.view", layout.name);
|
||||
compare::entities(a, b, &layout.entities, &back.entities, &format!("{context}.layout[{}]", layout.name));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
//! Shared preservation assertions for ordered entities, authored blocks and referenced tables.
|
||||
//! Every field of the explicitly supported entities is checked; converted angles alone allow
|
||||
//! floating-point roundoff, never wrapping or geometric approximation. Blocks are visited once
|
||||
//! as stored definitions, not recursively expanded, so cyclic references remain bounded.
|
||||
//! Header defaults, generated objects and other drawing collections are outside this slice.
|
||||
|
||||
use std::fmt::Debug;
|
||||
|
||||
use cadcraft_doc::{Arc, Attrib, Block, Circle, Common, Drawing, Entity, EntityKind, EntityStore, Insert, Line, MText, Text};
|
||||
|
||||
fn equal<T: PartialEq + Debug>(expected: &T, actual: &T, context: &str, field: &str) {
|
||||
assert_eq!(actual, expected, "{context}.{field}");
|
||||
}
|
||||
|
||||
fn angle(expected: f64, actual: f64, context: &str, field: &str) {
|
||||
// Two floating-point unit conversions, not a geometric approximation or angle wrapping.
|
||||
let tolerance = 8.0 * f64::EPSILON * expected.abs().max(1.0);
|
||||
assert!(
|
||||
expected.is_finite() && actual.is_finite() && (actual - expected).abs() <= tolerance,
|
||||
"{context}.{field}: expected {expected:?}, actual {actual:?}, tolerance {tolerance:e}"
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn model(expected: &Drawing, actual: &Drawing, context: &str) {
|
||||
entities(expected, actual, &expected.model, &actual.model, &format!("{context}.model"));
|
||||
for (name, block) in &expected.blocks {
|
||||
let path = format!("{context}.block[{name}]");
|
||||
let back = actual.block(name).unwrap_or_else(|| panic!("{path}: missing block"));
|
||||
let Block {
|
||||
name,
|
||||
annotative,
|
||||
base,
|
||||
entities: contents,
|
||||
description,
|
||||
xref_overlay,
|
||||
xref_status,
|
||||
anonymous,
|
||||
xref_path,
|
||||
explodable,
|
||||
units,
|
||||
array,
|
||||
} = block.as_ref();
|
||||
equal(name, &back.name, &path, "name");
|
||||
equal(annotative, &back.annotative, &path, "annotative");
|
||||
equal(base, &back.base, &path, "base");
|
||||
equal(description, &back.description, &path, "description");
|
||||
equal(xref_overlay, &back.xref_overlay, &path, "xref_overlay");
|
||||
equal(xref_status, &back.xref_status, &path, "xref_status");
|
||||
equal(anonymous, &back.anonymous, &path, "anonymous");
|
||||
equal(xref_path, &back.xref_path, &path, "xref_path");
|
||||
equal(explodable, &back.explodable, &path, "explodable");
|
||||
equal(units, &back.units, &path, "units");
|
||||
equal(array, &back.array, &path, "array");
|
||||
entities(expected, actual, contents, &back.entities, &path);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn entities(expected: &Drawing, actual: &Drawing, source: &EntityStore, back: &EntityStore, context: &str) {
|
||||
equal(&source.len(), &back.len(), context, "len");
|
||||
for (index, (a, b)) in source.iter().zip(back.iter()).enumerate() {
|
||||
let path = format!("{context}.entity[{index}]");
|
||||
entity(a, b, &path);
|
||||
layer_reference(expected, actual, &a.common.layer, &path);
|
||||
linetype_reference(expected, actual, &a.common.linetype, &path);
|
||||
let dimension_style = match &a.kind {
|
||||
EntityKind::Dimension(d) => Some(d.style.as_str()),
|
||||
EntityKind::Leader(l) => Some(l.style.as_str()),
|
||||
EntityKind::Tolerance(t) => Some(t.style.as_str()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(name) = dimension_style {
|
||||
equal(&expected.dim_style(name), &actual.dim_style(name), &path, "referenced_dimension_style");
|
||||
if let Some(style) = expected.dim_style(name) {
|
||||
equal(&expected.text_style(&style.text_style), &actual.text_style(&style.text_style), &path, "dimension_text_style");
|
||||
}
|
||||
}
|
||||
let style = match &a.kind {
|
||||
EntityKind::Text(t) => Some(t.style.as_str()),
|
||||
EntityKind::MText(t) => Some(t.style.as_str()),
|
||||
EntityKind::MLeader(m) => m.text.as_ref().map(|t| t.style.as_str()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(style) = style {
|
||||
equal(&expected.text_style(style), &actual.text_style(style), &path, "referenced_text_style");
|
||||
}
|
||||
let attributes: &[Attrib] = match &a.kind {
|
||||
EntityKind::AttDef(a) => std::slice::from_ref(a),
|
||||
EntityKind::Insert(i) => &i.attribs,
|
||||
EntityKind::MLeader(m) => m.block.as_ref().map_or(&[], |i| i.attribs.as_slice()),
|
||||
_ => &[],
|
||||
};
|
||||
for (index, a) in attributes.iter().enumerate() {
|
||||
let path = format!("{path}.attrib[{index}]");
|
||||
if let Some(name) = &a.props.layer {
|
||||
layer_reference(expected, actual, name, &path);
|
||||
}
|
||||
if let Some(name) = &a.props.linetype {
|
||||
linetype_reference(expected, actual, name, &path);
|
||||
}
|
||||
for style in std::iter::once(a.text.style.as_str()).chain(a.multiline.as_ref().map(|m| m.mtext.style.as_str())) {
|
||||
equal(&expected.text_style(style), &actual.text_style(style), &path, "referenced_text_style");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn layer_reference(expected: &Drawing, actual: &Drawing, name: &str, context: &str) {
|
||||
let layer = expected.layer(name);
|
||||
equal(&layer, &actual.layer(name), context, "referenced_layer");
|
||||
if let Some(layer) = layer {
|
||||
linetype_reference(expected, actual, &layer.linetype, context);
|
||||
}
|
||||
}
|
||||
|
||||
fn linetype_reference(expected: &Drawing, actual: &Drawing, name: &str, context: &str) {
|
||||
linetype(expected.linetype(name), actual.linetype(name), &format!("{context}.linetype[{name}]"));
|
||||
if let Some(linetype) = expected.linetype(name) {
|
||||
for element in &linetype.pattern {
|
||||
if let Some(style) = &element.style {
|
||||
equal(&expected.text_style(style), &actual.text_style(style), context, "linetype_text_style");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn linetype(expected: Option<&cadcraft_doc::Linetype>, actual: Option<&cadcraft_doc::Linetype>, context: &str) {
|
||||
let (Some(a), Some(b)) = (expected, actual) else {
|
||||
equal(&expected, &actual, context, "presence");
|
||||
return;
|
||||
};
|
||||
let cadcraft_doc::Linetype { name, description, pattern } = a;
|
||||
equal(name, &b.name, context, "name");
|
||||
equal(description, &b.description, context, "description");
|
||||
equal(&pattern.len(), &b.pattern.len(), context, "pattern.len");
|
||||
for (index, (a, b)) in pattern.iter().zip(&b.pattern).enumerate() {
|
||||
let path = format!("{context}.element[{index}]");
|
||||
let cadcraft_doc::DashElement { length, text, shape, style, scale, rotation, offset, absolute } = a;
|
||||
equal(length, &b.length, &path, "length");
|
||||
equal(text, &b.text, &path, "text");
|
||||
equal(shape, &b.shape, &path, "shape");
|
||||
equal(style, &b.style, &path, "style");
|
||||
equal(scale, &b.scale, &path, "scale");
|
||||
angle(*rotation, b.rotation, &path, "rotation");
|
||||
equal(offset, &b.offset, &path, "offset");
|
||||
equal(absolute, &b.absolute, &path, "absolute");
|
||||
}
|
||||
}
|
||||
|
||||
fn entity(expected: &Entity, actual: &Entity, context: &str) {
|
||||
let Entity { handle, common: properties, kind } = expected;
|
||||
equal(handle, &actual.handle, context, "handle");
|
||||
common(properties, &actual.common, &format!("{context}.common"));
|
||||
match (kind, &actual.kind) {
|
||||
(EntityKind::Line(Line { a, b }), EntityKind::Line(other)) => {
|
||||
equal(a, &other.a, context, "line.a");
|
||||
equal(b, &other.b, context, "line.b");
|
||||
}
|
||||
(EntityKind::Circle(Circle { center, radius }), EntityKind::Circle(other)) => {
|
||||
equal(center, &other.center, context, "circle.center");
|
||||
equal(radius, &other.radius, context, "circle.radius");
|
||||
}
|
||||
(EntityKind::Text(a), EntityKind::Text(b)) => text(a, b, &format!("{context}.text")),
|
||||
(EntityKind::Arc(Arc { center, radius, start, end }), EntityKind::Arc(b)) => {
|
||||
equal(center, &b.center, context, "arc.center");
|
||||
equal(radius, &b.radius, context, "arc.radius");
|
||||
angle(*start, b.start, context, "arc.start");
|
||||
angle(*end, b.end, context, "arc.end");
|
||||
}
|
||||
(EntityKind::MText(a), EntityKind::MText(b)) => mtext(a, b, context),
|
||||
(EntityKind::AttDef(a), EntityKind::AttDef(b)) => attrib(a, b, context),
|
||||
(EntityKind::Insert(a), EntityKind::Insert(b)) => insert(a, b, context),
|
||||
(
|
||||
EntityKind::Point(_)
|
||||
| EntityKind::Ellipse(_)
|
||||
| EntityKind::LwPolyline(_)
|
||||
| EntityKind::Polyline3d(_)
|
||||
| EntityKind::Spline(_)
|
||||
| EntityKind::Ray(_)
|
||||
| EntityKind::XLine(_)
|
||||
| EntityKind::Solid(_)
|
||||
| EntityKind::Trace(_)
|
||||
| EntityKind::Face3d(_)
|
||||
| EntityKind::Leader(_)
|
||||
| EntityKind::Hatch(_)
|
||||
| EntityKind::Table(_)
|
||||
| EntityKind::Image(_)
|
||||
| EntityKind::Wipeout(_)
|
||||
| EntityKind::Helix(_)
|
||||
| EntityKind::MLine(_)
|
||||
| EntityKind::MLeader(_)
|
||||
| EntityKind::Region(_)
|
||||
| EntityKind::Dimension(_)
|
||||
| EntityKind::Tolerance(_)
|
||||
| EntityKind::Viewport(_),
|
||||
_,
|
||||
) => equal(kind, &actual.kind, context, "kind"),
|
||||
_ => panic!("{context}.kind: unsupported or changed type: expected {kind:?}, actual {:?}", actual.kind),
|
||||
}
|
||||
}
|
||||
|
||||
fn common(expected: &Common, actual: &Common, context: &str) {
|
||||
// No `..`: adding a field requires deciding its preservation contract here.
|
||||
let Common { layer, color, linetype, lineweight, ltscale, transparency, visible, thickness, extrusion, plot_style, anno_scales, xdata } =
|
||||
expected;
|
||||
equal(layer, &actual.layer, context, "layer");
|
||||
equal(color, &actual.color, context, "color");
|
||||
equal(linetype, &actual.linetype, context, "linetype");
|
||||
equal(lineweight, &actual.lineweight, context, "lineweight");
|
||||
equal(ltscale, &actual.ltscale, context, "ltscale");
|
||||
equal(transparency, &actual.transparency, context, "transparency");
|
||||
equal(visible, &actual.visible, context, "visible");
|
||||
equal(thickness, &actual.thickness, context, "thickness");
|
||||
equal(extrusion, &actual.extrusion, context, "extrusion");
|
||||
equal(plot_style, &actual.plot_style, context, "plot_style");
|
||||
equal(anno_scales, &actual.anno_scales, context, "anno_scales");
|
||||
equal(xdata, &actual.xdata, context, "xdata");
|
||||
}
|
||||
|
||||
fn text(expected: &Text, actual: &Text, context: &str) {
|
||||
let Text { insert, align_pt, height, value, rotation, width_factor, oblique, style, halign, valign } = expected;
|
||||
equal(insert, &actual.insert, context, "insert");
|
||||
equal(align_pt, &actual.align_pt, context, "align_pt");
|
||||
equal(height, &actual.height, context, "height");
|
||||
equal(value, &actual.value, context, "value");
|
||||
angle(*rotation, actual.rotation, context, "rotation");
|
||||
equal(width_factor, &actual.width_factor, context, "width_factor");
|
||||
angle(*oblique, actual.oblique, context, "oblique");
|
||||
equal(style, &actual.style, context, "style");
|
||||
equal(halign, &actual.halign, context, "halign");
|
||||
equal(valign, &actual.valign, context, "valign");
|
||||
}
|
||||
|
||||
fn mtext(expected: &MText, actual: &MText, context: &str) {
|
||||
let MText { insert, height, width, attach, rotation, style, contents, line_spacing, line_spacing_exact, columns } = expected;
|
||||
equal(insert, &actual.insert, context, "mtext.insert");
|
||||
equal(height, &actual.height, context, "mtext.height");
|
||||
equal(width, &actual.width, context, "mtext.width");
|
||||
equal(attach, &actual.attach, context, "mtext.attach");
|
||||
angle(*rotation, actual.rotation, context, "mtext.rotation");
|
||||
equal(style, &actual.style, context, "mtext.style");
|
||||
equal(contents, &actual.contents, context, "mtext.contents");
|
||||
equal(line_spacing, &actual.line_spacing, context, "mtext.line_spacing");
|
||||
equal(line_spacing_exact, &actual.line_spacing_exact, context, "mtext.line_spacing_exact");
|
||||
equal(columns, &actual.columns, context, "mtext.columns");
|
||||
}
|
||||
|
||||
fn attrib(expected: &Attrib, actual: &Attrib, context: &str) {
|
||||
let Attrib { tag, text: t, invisible, constant, prompt, props, multiline, modes } = expected;
|
||||
equal(tag, &actual.tag, context, "attrib.tag");
|
||||
text(t, &actual.text, context);
|
||||
equal(invisible, &actual.invisible, context, "attrib.invisible");
|
||||
equal(constant, &actual.constant, context, "attrib.constant");
|
||||
equal(prompt, &actual.prompt, context, "attrib.prompt");
|
||||
equal(props, &actual.props, context, "attrib.props");
|
||||
equal(modes, &actual.modes, context, "attrib.modes");
|
||||
match (multiline, &actual.multiline) {
|
||||
(Some(a), Some(b)) => {
|
||||
equal(&a.read_as, &b.read_as, context, "attrib.read_as");
|
||||
mtext(&a.mtext, &b.mtext, context);
|
||||
}
|
||||
_ => equal(multiline, &actual.multiline, context, "attrib.multiline"),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(expected: &Insert, actual: &Insert, context: &str) {
|
||||
let Insert { block, insert, scale, rotation, attribs, cols, rows, col_spacing, row_spacing } = expected;
|
||||
equal(block, &actual.block, context, "insert.block");
|
||||
equal(insert, &actual.insert, context, "insert.insert");
|
||||
equal(scale, &actual.scale, context, "insert.scale");
|
||||
angle(*rotation, actual.rotation, context, "insert.rotation");
|
||||
equal(cols, &actual.cols, context, "insert.cols");
|
||||
equal(rows, &actual.rows, context, "insert.rows");
|
||||
equal(col_spacing, &actual.col_spacing, context, "insert.col_spacing");
|
||||
equal(row_spacing, &actual.row_spacing, context, "insert.row_spacing");
|
||||
equal(&attribs.len(), &actual.attribs.len(), context, "insert.attribs.len");
|
||||
for (index, (a, b)) in attribs.iter().zip(&actual.attribs).enumerate() {
|
||||
attrib(a, b, &format!("{context}.attrib[{index}]"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comparison_rejects_lost_fields_and_changed_handles() {
|
||||
use cadcraft_doc::Handle;
|
||||
use cadcraft_geom::Vec3;
|
||||
|
||||
let original = Entity::new(Handle(0x123), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(2.0, 3.0, 4.0) }));
|
||||
let mutations: [fn(&mut Entity); 4] = [
|
||||
|e| e.handle = Handle(0x456),
|
||||
|e| e.common.visible = false,
|
||||
|e| e.common.xdata.push(cadcraft_doc::RawTag { code: 1000, value: "unexpected".into() }),
|
||||
|e| e.kind = EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 2.0 }),
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut changed = original.clone();
|
||||
mutate(&mut changed);
|
||||
assert!(std::panic::catch_unwind(|| entity(&original, &changed, "comparison self-test")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comparison_rejects_lost_or_changed_referenced_linetypes() {
|
||||
use cadcraft_doc::{Linetype, Space};
|
||||
use cadcraft_geom::Vec3;
|
||||
|
||||
for (name, entity_linetype, layer_linetype) in [
|
||||
("direct", "ReviewDashed", "Continuous"),
|
||||
("mixed-case", "reviewdashed", "Continuous"),
|
||||
("inherited", "ByLayer", "ReviewDashed"),
|
||||
("inherited-mixed-case", "ByLayer", "reviewdashed"),
|
||||
] {
|
||||
let mut original = Drawing::new_metric();
|
||||
original.linetypes.push(Linetype::simple("ReviewDashed", "Synthetic", &[2.0, -1.0]));
|
||||
original.layer_mut("0").unwrap().linetype = layer_linetype.into();
|
||||
original
|
||||
.add(
|
||||
&Space::Model,
|
||||
Common { linetype: entity_linetype.into(), ..Common::default() },
|
||||
EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 2.0, 3.0) }),
|
||||
)
|
||||
.unwrap();
|
||||
let referenced = if entity_linetype == "ByLayer" { layer_linetype } else { entity_linetype };
|
||||
assert!(original.linetype(referenced).is_some(), "{name}: valid reference");
|
||||
model(&original, &original, name);
|
||||
let mutations: [fn(&mut Drawing); 2] = [
|
||||
|d| d.linetypes.retain(|l| l.name != "ReviewDashed"),
|
||||
|d| d.linetypes.iter_mut().find(|l| l.name == "ReviewDashed").unwrap().pattern.clear(),
|
||||
];
|
||||
for (index, mutate) in mutations.into_iter().enumerate() {
|
||||
let mut changed = original.clone();
|
||||
mutate(&mut changed);
|
||||
assert!(std::panic::catch_unwind(|| model(&original, &changed, name)).is_err(), "{name}: mutation {index} must be rejected");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comparison_rejects_block_and_attribute_dependency_losses() {
|
||||
use super::cases::{blocks, properties};
|
||||
let original = blocks::attributes("overrides").remove(0).drawing;
|
||||
let mutations: [fn(&mut Drawing); 4] = [
|
||||
|d| {
|
||||
d.blocks.remove("Z_Leaf");
|
||||
},
|
||||
|d| std::sync::Arc::make_mut(d.blocks.get_mut("Z_Leaf").unwrap()).base.x += 1.0,
|
||||
|d| d.layer_mut("Survey_Ä").unwrap().color = cadcraft_color::Color::Index(5),
|
||||
|d| {
|
||||
let handle = d.model.iter().next().unwrap().handle;
|
||||
d.model.modify(handle, |e| {
|
||||
let EntityKind::Insert(i) = &mut e.kind else { panic!("expected INSERT") };
|
||||
i.attribs.reverse();
|
||||
});
|
||||
},
|
||||
];
|
||||
model(&original, &original, "unchanged block and attributes");
|
||||
for mutate in mutations {
|
||||
let mut changed = original.clone();
|
||||
mutate(&mut changed);
|
||||
assert!(std::panic::catch_unwind(|| model(&original, &changed, "block dependency self-test")).is_err());
|
||||
}
|
||||
let original = properties::complex_linetypes(false).remove(0).drawing;
|
||||
let mut changed = original.clone();
|
||||
changed.text_styles.retain(|s| s.name != "Notes");
|
||||
assert!(std::panic::catch_unwind(|| model(&original, &changed, "linetype style self-test")).is_err());
|
||||
let cyclic = blocks::nested(true).remove(0).drawing;
|
||||
model(&cyclic, &cyclic, "cycles compare without expansion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn angle_tolerance_does_not_hide_real_changes_or_nonfinite_values() {
|
||||
angle(0.7, 0.7_f64.to_degrees().to_radians(), "self-test", "angle");
|
||||
for actual in [0.700001, f64::NAN, f64::INFINITY] {
|
||||
assert!(std::panic::catch_unwind(|| angle(0.7, actual, "self-test", "angle")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Independent bridge expectations, not an independent DWG codec. Writer checks inspect native
|
||||
//! objects without CADCraft's reader; reader fixtures originate in native objects without our
|
||||
//! DXF writer. acadrust still supplies the binary codec in both directions, so these tests do
|
||||
//! not establish interoperability with another DWG implementation. All fixtures are synthetic.
|
||||
|
||||
use super::super::cases;
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::{CadDocument, DwgReader, DwgWriter, DxfVersion, EntityType};
|
||||
use cadcraft_doc::{Circle, Common, EntityKind, Line, Text};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn writer_populates_native_geometry_without_our_reader() {
|
||||
let source = cases::common_case("native-writer", Common::default());
|
||||
for version in Version::ALL {
|
||||
let bytes = write_with(&source.drawing, "memory.dwg", &SaveOptions { version: Some(version), binary: false }).unwrap();
|
||||
let native = DwgReader::from_stream(Cursor::new(bytes)).read().unwrap();
|
||||
let entities: Vec<_> = native.entities().collect();
|
||||
assert_eq!(entities.len(), 3, "{version}");
|
||||
let EntityType::Line(line) = entities[0] else { panic!("expected native LINE") };
|
||||
assert_eq!(line.start, Vector3::new(-1.25, 2.5, 3.75), "{version}");
|
||||
assert_eq!(line.end, Vector3::new(8.125, -13.5, 21.25), "{version}");
|
||||
let EntityType::Circle(circle) = entities[1] else { panic!("expected native CIRCLE") };
|
||||
assert_eq!(circle.center, Vector3::new(-7.25, 11.5, 4.75), "{version}");
|
||||
assert_eq!(circle.radius, 3.125, "{version}");
|
||||
let EntityType::Text(text) = entities[2] else { panic!("expected native TEXT") };
|
||||
assert_eq!(text.value, "CAD Ø", "{version}");
|
||||
assert_eq!(text.insertion_point, Vector3::new(2.5, -3.75, 4.125), "{version}");
|
||||
assert_eq!(text.height, 2.25, "{version}");
|
||||
assert_eq!(text.width_factor, 1.0, "{version}");
|
||||
assert_eq!(text.rotation, 0.0, "{version}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Confirmed native DWG mismatch: a pi/2 linetype rotation is stored as pi/2 degrees; DXF unit interpretation needs investigation"]
|
||||
fn complex_linetype_rotation_is_correct_in_native_objects() {
|
||||
// Both document APIs specify radians. Do not infer correctness from a DXF round trip:
|
||||
// reader and writer can share a unit error. Public DXF references disagree about LTYPE
|
||||
// group 50, so this asserts the end-to-end native angle, not a guessed DXF encoding.
|
||||
for shape in [false, true] {
|
||||
for case in cases::properties::complex_linetypes(shape) {
|
||||
for version in Version::ALL {
|
||||
let bytes = write_with(&case.drawing, "memory.dwg", &SaveOptions { version: Some(version), binary: false }).unwrap();
|
||||
let native = DwgReader::from_stream(Cursor::new(bytes)).read().unwrap();
|
||||
let line = native.line_types.iter().find(|l| l.name == "Dash_test").unwrap();
|
||||
let elements: Vec<_> = line.elements.iter().filter_map(|e| e.complex.as_ref()).collect();
|
||||
assert_eq!(elements.len(), 1, "{} {version}", case.name);
|
||||
let rotation = elements[0].rotation;
|
||||
assert!(
|
||||
(rotation - std::f64::consts::FRAC_PI_2).abs() <= 8.0 * f64::EPSILON,
|
||||
"{} {version}: native rotation {rotation} radians, expected pi/2",
|
||||
case.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reader_accepts_native_fixtures_without_our_writer() {
|
||||
for version in [DxfVersion::AC1015, DxfVersion::AC1018, DxfVersion::AC1021, DxfVersion::AC1024, DxfVersion::AC1027, DxfVersion::AC1032] {
|
||||
let mut native = CadDocument::with_version(version);
|
||||
let handles = [
|
||||
native.add_entity(EntityType::Line(acadrust::entities::Line::from_coords(1.25, -2.5, 3.75, -4.5, 5.25, -6.75))).unwrap(),
|
||||
native.add_entity(EntityType::Circle(acadrust::entities::Circle::from_coords(7.25, -8.5, 9.75, 2.125))).unwrap(),
|
||||
native.add_entity(EntityType::Text(acadrust::entities::Text::with_value("Independent Ø", Vector3::new(-3.25, 4.5, 1.75)))).unwrap(),
|
||||
];
|
||||
let expected = [
|
||||
EntityKind::Line(Line { a: Vec3::new(1.25, -2.5, 3.75), b: Vec3::new(-4.5, 5.25, -6.75) }),
|
||||
EntityKind::Circle(Circle { center: Vec3::new(7.25, -8.5, 9.75), radius: 2.125 }),
|
||||
EntityKind::Text(Text { insert: Vec3::new(-3.25, 4.5, 1.75), height: 1.0, value: "Independent Ø".into(), ..cases::text() }),
|
||||
];
|
||||
let bytes = DwgWriter::write_to_vec(&native).unwrap();
|
||||
let back = read(&bytes, "native-fixture.dwg").unwrap();
|
||||
assert!(back.read_notes.is_empty(), "{:?}", back.read_notes);
|
||||
assert!(back.skipped_records.is_empty());
|
||||
assert_eq!(back.model.len(), expected.len());
|
||||
for ((entity, expected), handle) in back.model.iter().zip(&expected).zip(handles) {
|
||||
assert_eq!(entity.handle.0, u64::from(handle), "{version:?}");
|
||||
assert_eq!(&entity.kind, expected, "{version:?}");
|
||||
assert_eq!(entity.common, Common::default(), "{version:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! DWG anonymous dimension-block suffixes are not persistent identities (#2257). Match blocks
|
||||
//! through dimensions with preserved entity handles, then compare authored content and properties
|
||||
//! strictly. Only anonymous *D<number> names may change; missing references and merged blocks fail.
|
||||
//! This verifier covers the model-space dimension fixtures, not arbitrary drawing graph remapping.
|
||||
//!
|
||||
//! Autodesk documents that anonymous block numbers may change between drawing sessions:
|
||||
//! https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-AutoLISP/files/GUID-EB9BFDAA-487A-4586-A556-1F5F84BCF2F3.htm
|
||||
//! ODA's DWG specification §20.4.52.1 shows a BLOCK_HEADER named *D without a suffix;
|
||||
//! DIMENSION records reference their block by handle, not by its reconstructed name:
|
||||
//! https://www.opendesign.com/files/guestdownloads/OpenDesign_Specification_for_.dwg_files.pdf
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Block, Circle, Drawing, Entity, EntityKind, Handle, Space};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::Version;
|
||||
|
||||
use super::super::{
|
||||
cases::{self, Case, complex},
|
||||
compare,
|
||||
};
|
||||
|
||||
pub(super) fn check() {
|
||||
for case in complex::dimensions().into_iter().chain([paired_dimensions(["*D42", "*D7"])]) {
|
||||
for version in Version::ALL {
|
||||
let mut expected = case.drawing.clone();
|
||||
// DIMFXL has no native field before R2007 (same limit as other dimension-style cases).
|
||||
if version < Version::R2007 {
|
||||
for style in &mut expected.dim_styles {
|
||||
style.fixed_ext_length = 0.0;
|
||||
}
|
||||
}
|
||||
super::check_version_using(&case.name, &case.drawing, &expected, version, equivalent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two independent cache blocks with different authored geometry, plus an ordinary named block.
|
||||
// Names are supplied explicitly so comparator controls do not learn expectations from a save.
|
||||
fn paired_dimensions(names: [&str; 2]) -> Case {
|
||||
let mut drawing = cases::drawing();
|
||||
for (index, (case, name)) in complex::dimensions().into_iter().zip(names).enumerate() {
|
||||
let entity = case.drawing.model.iter().next().unwrap();
|
||||
let mut kind = entity.kind.clone();
|
||||
let EntityKind::Dimension(dim) = &mut kind else { panic!("fixture dimension") };
|
||||
dim.block = Some(name.into());
|
||||
drawing.add(&Space::Model, entity.common.clone(), kind).unwrap();
|
||||
let mut block = Block::new(name);
|
||||
for entity in case.drawing.block("*D42").unwrap().entities.iter() {
|
||||
block.entities.push(Entity { handle: drawing.new_handle(), ..entity.as_ref().clone() });
|
||||
}
|
||||
// This circle is authored cache content, not something dimension regeneration produces.
|
||||
block
|
||||
.entities
|
||||
.push(Entity::new(drawing.new_handle(), EntityKind::Circle(Circle { center: Vec3::new(5.0, 3.0, 0.0), radius: 0.25 + index as f64 })));
|
||||
drawing.blocks.insert(name.into(), Arc::new(block));
|
||||
}
|
||||
let mut named = Block::new("UserPart");
|
||||
named.entities.push(Entity::new(drawing.new_handle(), EntityKind::Circle(Circle { center: Vec3::new(1.0, 2.0, 0.0), radius: 2.0 })));
|
||||
drawing.blocks.insert(named.name.clone(), Arc::new(named));
|
||||
Case { name: "dimension/distinct-anonymous-caches-and-named-block".into(), drawing }
|
||||
}
|
||||
|
||||
fn anonymous_dimension_name(name: &str) -> bool {
|
||||
name.strip_prefix("*D").is_some_and(|suffix| !suffix.is_empty() && suffix.bytes().all(|c| c.is_ascii_digit()))
|
||||
}
|
||||
|
||||
fn equivalent(expected: &Drawing, actual: &Drawing, context: &str) {
|
||||
let mut names = BTreeMap::new();
|
||||
for entity in expected.model.iter() {
|
||||
let EntityKind::Dimension(dim) = &entity.kind else { continue };
|
||||
let before = dim.block.as_deref().expect("fixture has a dimension block");
|
||||
let back = actual.entity(entity.handle).unwrap_or_else(|| panic!("{context}: missing dimension {}", entity.handle.hex()));
|
||||
let EntityKind::Dimension(dim) = &back.kind else { panic!("{context}: dimension changed type") };
|
||||
let after = dim.block.as_deref().unwrap_or_else(|| panic!("{context}: dimension lost its block reference"));
|
||||
let source = expected.block(before).expect("fixture block exists");
|
||||
let target = actual.block(after).unwrap_or_else(|| panic!("{context}: dangling dimension block reference {after}"));
|
||||
if before != after {
|
||||
assert!(source.anonymous && target.anonymous, "{context}: only anonymous dimension blocks may be renamed");
|
||||
assert!(
|
||||
anonymous_dimension_name(before) && anonymous_dimension_name(after),
|
||||
"{context}: unexpected block name mapping {before} -> {after}"
|
||||
);
|
||||
}
|
||||
if let Some(previous) = names.insert(before.to_string(), after.to_string()) {
|
||||
assert_eq!(previous, after, "{context}: one dimension block split into different references");
|
||||
}
|
||||
}
|
||||
assert!(!names.is_empty(), "{context}: fixture must exercise dimension block references");
|
||||
assert_eq!(names.values().collect::<BTreeSet<_>>().len(), names.len(), "{context}: distinct dimension blocks were merged");
|
||||
assert_eq!(actual.blocks.len(), expected.blocks.len(), "{context}: block lost or extra cache generated");
|
||||
|
||||
// Adapt only the validated names, never content or properties learned from the output.
|
||||
// Rebuild the map to handle renumberings that reuse another source block's old name.
|
||||
let mut renamed = expected.clone();
|
||||
renamed.blocks.clear();
|
||||
for (name, block) in &expected.blocks {
|
||||
let mapped = names.get(name).unwrap_or(name);
|
||||
let mut block = block.clone();
|
||||
Arc::make_mut(&mut block).name = mapped.clone();
|
||||
assert!(renamed.blocks.insert(mapped.clone(), block).is_none(), "{context}: block name collision");
|
||||
}
|
||||
for entity in expected.model.iter() {
|
||||
if let EntityKind::Dimension(dim) = &entity.kind {
|
||||
let mapped = names.get(dim.block.as_ref().expect("fixture block")).expect("mapped reference");
|
||||
renamed.model.modify(entity.handle, |e| {
|
||||
let EntityKind::Dimension(dim) = &mut e.kind else { panic!("fixture dimension") };
|
||||
dim.block = Some(mapped.clone());
|
||||
});
|
||||
}
|
||||
}
|
||||
compare::model(&renamed, actual, context);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comparison_accepts_only_anonymous_dimension_renumbering() {
|
||||
let original = paired_dimensions(["*D42", "*D7"]).drawing;
|
||||
equivalent(&original, &original, "unchanged");
|
||||
for names in [["*D1", "*D2"], ["*D7", "*D42"]] {
|
||||
let renamed = paired_dimensions(names).drawing;
|
||||
equivalent(&original, &renamed, "renumbered, including reused source names");
|
||||
// The general comparator must still reject renaming outside this DWG-specific check.
|
||||
assert!(std::panic::catch_unwind(|| compare::model(&original, &renamed, "strict comparison")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn dimension_handles(d: &Drawing) -> Vec<Handle> {
|
||||
d.model.iter().filter(|e| matches!(e.kind, EntityKind::Dimension(_))).map(|e| e.handle).collect()
|
||||
}
|
||||
|
||||
fn set_reference(d: &mut Drawing, handle: Handle, name: Option<&str>) {
|
||||
assert!(d.model.modify(handle, |e| {
|
||||
let EntityKind::Dimension(dim) = &mut e.kind else { panic!("fixture dimension") };
|
||||
dim.block = name.map(str::to_string);
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comparison_rejects_broken_references_content_and_block_properties() {
|
||||
let original = paired_dimensions(["*D42", "*D7"]).drawing;
|
||||
let renamed = paired_dimensions(["*D1", "*D2"]).drawing;
|
||||
equivalent(&original, &renamed, "valid control");
|
||||
let mutations: [fn(&mut Drawing); 14] = [
|
||||
|d| set_reference(d, dimension_handles(d)[0], None),
|
||||
|d| set_reference(d, dimension_handles(d)[0], Some("*D999")),
|
||||
|d| set_reference(d, dimension_handles(d)[0], Some("*D2")),
|
||||
|d| {
|
||||
let hs = dimension_handles(d);
|
||||
set_reference(d, hs[0], Some("*D2"));
|
||||
set_reference(d, hs[1], Some("*D1"));
|
||||
},
|
||||
|d| {
|
||||
d.blocks.remove("*D1");
|
||||
},
|
||||
|d| Arc::make_mut(d.blocks.get_mut("*D1").unwrap()).entities = Default::default(),
|
||||
|d| {
|
||||
let block = Arc::make_mut(d.blocks.get_mut("*D1").unwrap());
|
||||
let h = block.entities.iter().next().unwrap().handle;
|
||||
block.entities.modify(h, |e| e.common.visible = false);
|
||||
},
|
||||
|d| {
|
||||
let block = Arc::make_mut(d.blocks.get_mut("*D1").unwrap());
|
||||
let h = block.entities.iter().last().unwrap().handle;
|
||||
block.entities.modify(h, |e| {
|
||||
let EntityKind::Circle(circle) = &mut e.kind else { panic!("fixture circle") };
|
||||
circle.radius += 1.0;
|
||||
});
|
||||
},
|
||||
|d| {
|
||||
let block = Arc::make_mut(d.blocks.get_mut("*D1").unwrap());
|
||||
let h = block.entities.iter().next().unwrap().handle;
|
||||
block.entities.modify(h, |e| e.handle = Handle(0xFFFF));
|
||||
},
|
||||
|d| Arc::make_mut(d.blocks.get_mut("*D1").unwrap()).base.x += 1.0,
|
||||
|d| Arc::make_mut(d.blocks.get_mut("*D1").unwrap()).anonymous = false,
|
||||
|d| {
|
||||
d.blocks.insert("*D999".into(), Arc::new(Block::new("*D999")));
|
||||
},
|
||||
|d| {
|
||||
let mut block = d.blocks.remove("UserPart").unwrap();
|
||||
Arc::make_mut(&mut block).name = "RenamedPart".into();
|
||||
d.blocks.insert("RenamedPart".into(), block);
|
||||
},
|
||||
|d| {
|
||||
let h = dimension_handles(d)[0];
|
||||
d.model.modify(h, |e| {
|
||||
let EntityKind::Dimension(dim) = &mut e.kind else { panic!("fixture dimension") };
|
||||
dim.p14.x += 1.0;
|
||||
});
|
||||
},
|
||||
];
|
||||
for (index, mutate) in mutations.into_iter().enumerate() {
|
||||
let mut changed = renamed.clone();
|
||||
mutate(&mut changed);
|
||||
assert!(std::panic::catch_unwind(|| equivalent(&original, &changed, "mutation")).is_err(), "mutation {index} must fail");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comparison_rejects_changed_prefixes_and_named_dimension_cache_renaming() {
|
||||
let original = paired_dimensions(["*D42", "*D7"]).drawing;
|
||||
for name in ["*U1", "*D", "*Dcustom", "UserCache"] {
|
||||
let changed = paired_dimensions([name, "*D2"]).drawing;
|
||||
assert!(std::panic::catch_unwind(|| equivalent(&original, &changed, "wrong name kind")).is_err());
|
||||
}
|
||||
let original = paired_dimensions(["AuthoredCache", "*D7"]).drawing;
|
||||
let changed = paired_dimensions(["RenamedCache", "*D2"]).drawing;
|
||||
assert!(std::panic::catch_unwind(|| equivalent(&original, &changed, "named cache")).is_err());
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! DWG property families stay separate: a failing escape, plot-style or alignment regression
|
||||
//! must not disable unrelated TEXT or Common tests. Pending cases assert the original model,
|
||||
//! never today's lossy output. Only confirmed failures receive an explicit ignore reason.
|
||||
|
||||
use cadcraft_color::{Color, Rgb};
|
||||
use cadcraft_doc::{Common, EntityKind, Transparency};
|
||||
use cadcraft_io::Version;
|
||||
|
||||
use super::super::cases;
|
||||
use super::{check, check_version};
|
||||
|
||||
#[test]
|
||||
fn empty_model_and_defaults() {
|
||||
check(cases::empty_and_defaults());
|
||||
}
|
||||
#[test]
|
||||
fn line_endpoints_and_circle_centers_radii() {
|
||||
check(cases::geometry());
|
||||
}
|
||||
#[test]
|
||||
fn layers() {
|
||||
check(cases::layers());
|
||||
}
|
||||
#[test]
|
||||
fn layer_inherited_linetypes() {
|
||||
check(cases::layer_linetypes());
|
||||
}
|
||||
#[test]
|
||||
fn colors() {
|
||||
check(cases::colors());
|
||||
}
|
||||
#[test]
|
||||
fn linetypes() {
|
||||
check(cases::linetypes());
|
||||
}
|
||||
#[test]
|
||||
fn lineweights() {
|
||||
check(cases::lineweights());
|
||||
}
|
||||
#[test]
|
||||
fn linetype_scales() {
|
||||
check(cases::linetype_scales());
|
||||
}
|
||||
#[test]
|
||||
fn transparencies() {
|
||||
check(cases::transparencies());
|
||||
}
|
||||
#[test]
|
||||
fn visibility() {
|
||||
check(cases::visibility());
|
||||
}
|
||||
#[test]
|
||||
fn thicknesses() {
|
||||
check(cases::thicknesses());
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Confirmed DWG bridge loss: named plot style Review becomes ByLayer, including R2018"]
|
||||
fn named_plot_style_is_preserved() {
|
||||
check(cases::plot_styles());
|
||||
}
|
||||
#[test]
|
||||
fn foreign_xdata() {
|
||||
check(cases::foreign_xdata());
|
||||
}
|
||||
#[test]
|
||||
fn common_property_interactions() {
|
||||
check(cases::common_interactions("ByLayer"));
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Confirmed DWG bridge loss: named plot style is lost in combination with other properties"]
|
||||
fn common_property_interactions_with_named_plot_style() {
|
||||
check(cases::common_interactions("Review"));
|
||||
}
|
||||
#[test]
|
||||
fn text_values() {
|
||||
check(cases::text_values());
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Confirmed DWG bridge loss: literal Unicode/MIF escape text is decoded again, including R2018"]
|
||||
fn text_literal_escapes() {
|
||||
check(cases::text_literal_escapes());
|
||||
}
|
||||
#[test]
|
||||
fn text_heights() {
|
||||
check(cases::text_heights());
|
||||
}
|
||||
#[test]
|
||||
fn text_rotations() {
|
||||
check(cases::text_rotations());
|
||||
}
|
||||
#[test]
|
||||
fn text_widths() {
|
||||
check(cases::text_widths());
|
||||
}
|
||||
#[test]
|
||||
fn text_obliques() {
|
||||
check(cases::text_obliques());
|
||||
}
|
||||
#[test]
|
||||
fn text_styles() {
|
||||
check(cases::text_styles());
|
||||
}
|
||||
#[test]
|
||||
fn annotation_scales() {
|
||||
check(cases::annotation_scales());
|
||||
}
|
||||
#[test]
|
||||
fn text_alignment_with_points() {
|
||||
check(cases::coplanar_text_alignment());
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Confirmed change: missing justified TEXT align_pt becomes (0,0,elevation), moving text"]
|
||||
fn text_alignment_without_points() {
|
||||
check(cases::text_alignment(false));
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Confirmed loss on the shared DXF path: ByBlock plot style becomes ByLayer"]
|
||||
fn plot_style_byblock_is_preserved() {
|
||||
check(cases::byblock_plot_style());
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Confirmed loss on the shared DXF path: non-default extrusion becomes (0,0,1)"]
|
||||
fn nondefault_extrusion_is_preserved() {
|
||||
check(cases::extrusions());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_points_share_one_elevation_in_dwg() {
|
||||
// Public ODA DWG specification, TEXT (1): insertion and alignment points contain XY;
|
||||
// a single elevation supplies both Z coordinates. This is a representational limit, not
|
||||
// a bug or a comparison tolerance. DXF's original XYZ cases are still checked unchanged.
|
||||
// https://www.opendesign.com/files/guestdownloads/OpenDesign_Specification_for_.dwg_files.pdf
|
||||
let cases = cases::noncoplanar_text_alignment();
|
||||
assert!(!cases.is_empty());
|
||||
for case in cases {
|
||||
let mut expected = case.drawing.clone();
|
||||
let handles: Vec<_> = expected.model.iter().map(|e| e.handle).collect();
|
||||
for handle in handles {
|
||||
expected.model.modify(handle, |e| {
|
||||
let EntityKind::Text(t) = &mut e.kind else { panic!("expected TEXT") };
|
||||
let point = t.align_pt.as_mut().expect("second point");
|
||||
point.z = t.insert.z;
|
||||
});
|
||||
}
|
||||
for version in Version::ALL {
|
||||
check_version(&case.name, &case.drawing, &expected, version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r2000_explicit_color_and_transparency_downgrade() {
|
||||
// R2000 has no true-color/alpha fields. An exact red RGB maps to ACI 1; neither a fixed
|
||||
// alpha nor transparency ByBlock is representable. Expected values are authored here,
|
||||
// not learnt from an initial save, and are checked again after the second save.
|
||||
for transparency in [Transparency::ByBlock, Transparency::Percent(0), Transparency::Percent(40), Transparency::Percent(90)] {
|
||||
let case = cases::common_case(
|
||||
&format!("legacy/true-red/transparency={transparency:?}"),
|
||||
Common { color: Color::True(Rgb(255, 0, 0)), transparency, ..Common::default() },
|
||||
);
|
||||
let mut expected = case.drawing.clone();
|
||||
let handles: Vec<_> = expected.model.iter().map(|e| e.handle).collect();
|
||||
for handle in handles {
|
||||
expected.model.modify(handle, |e| {
|
||||
e.common.color = Color::Index(1);
|
||||
e.common.transparency = Transparency::ByLayer;
|
||||
});
|
||||
}
|
||||
check_version(&case.name, &case.drawing, &expected, Version::R2000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
//! Native DWG extensions to the shared preservation matrix. Version limits (native columns,
|
||||
//! attributes, HELIX/MULTILEADER and table fields) are explicit, as are named-reference
|
||||
//! resolution and legacy defaults. Other inputs use the full save-version domain.
|
||||
|
||||
use super::super::cases::{Case, blocks, complex, geometry, properties};
|
||||
use super::super::collections;
|
||||
use super::check;
|
||||
use cadcraft_io::Version;
|
||||
|
||||
// DWG references a table record by handle, not by an independently spelled name. These
|
||||
// explicit expected spellings test resolution, not a blanket case-folding comparator.
|
||||
fn canonical_reference(kind: &str, name: &str) {
|
||||
for case in properties::mixed_case_references(kind) {
|
||||
let mut expected = case.drawing.clone();
|
||||
let handles: Vec<_> = expected.model.iter().map(|e| e.handle).collect();
|
||||
for handle in handles {
|
||||
expected.model.modify(handle, |e| match kind {
|
||||
"layer" => e.common.layer = name.into(),
|
||||
"linetype" => e.common.linetype = name.into(),
|
||||
"style" => {
|
||||
let cadcraft_doc::EntityKind::Text(t) = &mut e.kind else { panic!("expected TEXT") };
|
||||
t.style = name.into();
|
||||
}
|
||||
_ => panic!("unknown reference family"),
|
||||
});
|
||||
}
|
||||
for version in Version::ALL {
|
||||
super::check_version(&case.name, &case.drawing, &expected, version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_block_metadata() {
|
||||
// DWG BLOCK_HEADER gains insertion units and explodability in R2007 (ODA specification).
|
||||
for case in blocks::metadata() {
|
||||
let mut expected = case.drawing.clone();
|
||||
let block = std::sync::Arc::make_mut(expected.blocks.get_mut("Z_Leaf").unwrap());
|
||||
block.explodable = true;
|
||||
block.units = 0;
|
||||
for version in [Version::R2000, Version::R2004] {
|
||||
super::check_version(&case.name, &case.drawing, &expected, version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn since_2007(cases: Vec<Case>) {
|
||||
assert!(!cases.is_empty());
|
||||
for case in cases {
|
||||
for version in Version::ALL.into_iter().filter(|v| *v >= Version::R2007) {
|
||||
super::check_version(&case.name, &case.drawing, &case.drawing, version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_case_layers() {
|
||||
canonical_reference("layer", "Survey_Ä");
|
||||
}
|
||||
#[test]
|
||||
fn mixed_case_linetypes() {
|
||||
canonical_reference("linetype", "Dash_test");
|
||||
}
|
||||
#[test]
|
||||
fn mixed_case_styles() {
|
||||
canonical_reference("style", "Notes");
|
||||
}
|
||||
#[test]
|
||||
fn layer_flags() {
|
||||
check(properties::layer_flags());
|
||||
}
|
||||
#[test]
|
||||
fn layer_appearance() {
|
||||
check(properties::layer_appearance());
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Confirmed DWG loss: named layer plot style Review becomes Normal"]
|
||||
fn layer_plot_styles() {
|
||||
check(properties::layer_plot_styles());
|
||||
}
|
||||
#[test]
|
||||
fn text_style_metrics() {
|
||||
check(properties::text_style_metrics());
|
||||
}
|
||||
#[test]
|
||||
fn text_style_flags() {
|
||||
check(properties::text_style_flags());
|
||||
}
|
||||
#[test]
|
||||
fn text_style_fonts() {
|
||||
check(properties::text_style_fonts());
|
||||
}
|
||||
#[test]
|
||||
fn linetype_text() {
|
||||
check(properties::complex_linetypes(false));
|
||||
}
|
||||
#[test]
|
||||
fn linetype_shapes() {
|
||||
check(properties::complex_linetypes(true));
|
||||
}
|
||||
#[test]
|
||||
fn xdata_lists() {
|
||||
check(properties::xdata_lists());
|
||||
}
|
||||
#[test]
|
||||
fn xdata_points() {
|
||||
check(properties::xdata_points());
|
||||
}
|
||||
#[test]
|
||||
fn xdata_handles() {
|
||||
check(properties::xdata_handles());
|
||||
}
|
||||
#[test]
|
||||
fn handles_and_order() {
|
||||
check(properties::handles_and_order());
|
||||
}
|
||||
#[test]
|
||||
fn handles_near_integer_limits() {
|
||||
check(properties::handles_near_integer_limits());
|
||||
}
|
||||
#[test]
|
||||
fn degenerate_lines() {
|
||||
check(properties::degenerate_geometry());
|
||||
}
|
||||
#[test]
|
||||
fn layer_inheritance_overrides() {
|
||||
check(properties::layer_inheritance_overrides());
|
||||
}
|
||||
#[test]
|
||||
fn alignment_style_interactions() {
|
||||
check(properties::alignment_style_interactions());
|
||||
}
|
||||
#[test]
|
||||
fn nested_attributes() {
|
||||
check(blocks::nested_attributes());
|
||||
}
|
||||
#[test]
|
||||
fn text_interactions() {
|
||||
check(properties::text_interactions());
|
||||
}
|
||||
#[test]
|
||||
fn long_text() {
|
||||
check(properties::long_text());
|
||||
}
|
||||
#[test]
|
||||
fn arcs() {
|
||||
check(geometry::arcs());
|
||||
}
|
||||
#[test]
|
||||
fn ellipses() {
|
||||
check(geometry::ellipses());
|
||||
}
|
||||
#[test]
|
||||
fn polyline_constant_widths() {
|
||||
check(geometry::polylines(false));
|
||||
}
|
||||
#[test]
|
||||
fn polyline_variable_widths() {
|
||||
check(geometry::polylines(true));
|
||||
}
|
||||
#[test]
|
||||
fn polylines_3d() {
|
||||
check(geometry::polylines_3d());
|
||||
}
|
||||
#[test]
|
||||
fn splines() {
|
||||
check(geometry::splines(false));
|
||||
}
|
||||
#[test]
|
||||
fn rational_splines() {
|
||||
check(geometry::splines(true));
|
||||
}
|
||||
#[test]
|
||||
fn fit_splines() {
|
||||
check(geometry::spline_fit(false));
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Confirmed DWG loss: periodic closed fit spline reopens as an open spline"]
|
||||
fn closed_fit_splines() {
|
||||
check(geometry::spline_fit(true));
|
||||
}
|
||||
#[test]
|
||||
fn points_and_rays() {
|
||||
check(geometry::points_and_rays());
|
||||
}
|
||||
#[test]
|
||||
fn faces() {
|
||||
check(geometry::faces());
|
||||
}
|
||||
#[test]
|
||||
fn mtext_placement() {
|
||||
check(geometry::mtext_placement());
|
||||
}
|
||||
#[test]
|
||||
fn mtext_spacing() {
|
||||
check(geometry::mtext_spacing());
|
||||
}
|
||||
#[test]
|
||||
fn mtext_contents() {
|
||||
check(geometry::mtext_contents());
|
||||
}
|
||||
#[test]
|
||||
fn mtext_long() {
|
||||
check(geometry::mtext_long());
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Confirmed DWG loss: MTEXT columns disappear"]
|
||||
fn mtext_columns() {
|
||||
since_2007(geometry::mtext_columns());
|
||||
}
|
||||
#[test]
|
||||
fn leaders() {
|
||||
with_dimension_styles(geometry::leaders(false));
|
||||
}
|
||||
#[test]
|
||||
fn hatches() {
|
||||
check(geometry::hatches());
|
||||
}
|
||||
#[test]
|
||||
fn tables() {
|
||||
check(geometry::tables());
|
||||
}
|
||||
#[test]
|
||||
fn images() {
|
||||
check(geometry::images());
|
||||
}
|
||||
#[test]
|
||||
fn wipeouts() {
|
||||
check(geometry::wipeouts(true));
|
||||
}
|
||||
#[test]
|
||||
fn wipeout_implicit_closure() {
|
||||
// The format closes the stored boundary explicitly; this is not an extra editable corner.
|
||||
// Keep grip behavior separate from this serialization contract (#2256).
|
||||
for (case, expected) in geometry::wipeouts(false).into_iter().zip(geometry::wipeouts(true)) {
|
||||
for version in Version::ALL {
|
||||
super::check_version(&case.name, &case.drawing, &expected.drawing, version);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Confirmed shared DXF loss: LEADER vertex Z coordinates become zero"]
|
||||
fn leaders_3d() {
|
||||
check(geometry::leaders(true));
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Confirmed shared DXF loss: ATTRIB constant flag becomes false"]
|
||||
fn constant_attributes() {
|
||||
check(blocks::attributes("constant"));
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Confirmed DWG loss: a space following an MTEXT caret is consumed"]
|
||||
fn mtext_carets() {
|
||||
check(geometry::mtext_carets());
|
||||
}
|
||||
#[test]
|
||||
fn block_transformations() {
|
||||
check(blocks::transformations());
|
||||
}
|
||||
#[test]
|
||||
fn block_arrays() {
|
||||
check(blocks::arrays());
|
||||
}
|
||||
#[test]
|
||||
fn block_metadata() {
|
||||
since_2007(blocks::metadata());
|
||||
}
|
||||
#[test]
|
||||
fn nested_blocks() {
|
||||
check(blocks::nested(false));
|
||||
}
|
||||
#[test]
|
||||
fn cyclic_blocks() {
|
||||
check(blocks::nested(true));
|
||||
}
|
||||
#[test]
|
||||
fn unreferenced_blocks() {
|
||||
check(blocks::unreferenced());
|
||||
}
|
||||
#[test]
|
||||
fn attribute_flags() {
|
||||
check(blocks::attributes("flags"));
|
||||
}
|
||||
#[test]
|
||||
fn attribute_modes() {
|
||||
check(blocks::attributes("modes"));
|
||||
}
|
||||
#[test]
|
||||
fn attribute_lock() {
|
||||
since_2007(blocks::attributes("lock"));
|
||||
}
|
||||
#[test]
|
||||
fn multiline_attributes() {
|
||||
for case in blocks::multiline_attributes() {
|
||||
super::check_version(&case.name, &case.drawing, &case.drawing, Version::R2018);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn attribute_overrides() {
|
||||
check(blocks::attributes("overrides"));
|
||||
}
|
||||
#[test]
|
||||
fn helices() {
|
||||
since_2007(complex::helices());
|
||||
}
|
||||
#[test]
|
||||
fn mlines() {
|
||||
check(complex::mlines());
|
||||
}
|
||||
#[test]
|
||||
fn mleaders() {
|
||||
since_2007(complex::mleaders());
|
||||
}
|
||||
#[test]
|
||||
fn regions() {
|
||||
check(complex::regions());
|
||||
}
|
||||
#[test]
|
||||
fn tolerances() {
|
||||
with_dimension_styles(complex::tolerances());
|
||||
}
|
||||
#[test]
|
||||
fn dimensions() {
|
||||
super::dimensions::check();
|
||||
}
|
||||
|
||||
fn collection(case: Case, verify: fn(&cadcraft_doc::Drawing, &cadcraft_doc::Drawing, &str)) {
|
||||
for version in Version::ALL {
|
||||
super::check_version_with(&case.name, &case.drawing, &case.drawing, version, verify);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn header_variables() {
|
||||
collection(collections::headers(), collections::assert_headers);
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Confirmed DWG loss: current layer CLAYER becomes 0"]
|
||||
fn header_current_layer() {
|
||||
collection(collections::named_header_reference("CLAYER"), collections::assert_headers);
|
||||
}
|
||||
#[test]
|
||||
fn header_text_style() {
|
||||
collection(collections::named_header_reference("TEXTSTYLE"), collections::assert_headers);
|
||||
}
|
||||
#[test]
|
||||
fn unreferenced_tables() {
|
||||
collection(collections::symbol_tables(), collections::assert_symbol_tables);
|
||||
}
|
||||
#[test]
|
||||
fn foreign_dictionary_records() {
|
||||
collection(collections::foreign_records(), collections::assert_foreign_records);
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Confirmed DWG data loss: saving an unregistered entity succeeds but drops it instead of preserving it or returning an error"]
|
||||
fn unknown_entities_are_preserved_or_explicitly_refused() {
|
||||
for version in Version::ALL {
|
||||
super::super::unknown::check("memory.dwg", version, false);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn views_and_ucs() {
|
||||
collection(collections::views_and_ucs(), collections::assert_views_and_ucs);
|
||||
}
|
||||
#[test]
|
||||
fn tiled_viewports() {
|
||||
collection(collections::vports(), collections::assert_vports);
|
||||
}
|
||||
#[test]
|
||||
fn groups() {
|
||||
collection(collections::groups(), collections::assert_groups);
|
||||
}
|
||||
#[test]
|
||||
fn layer_states() {
|
||||
collection(collections::layer_states(), collections::assert_layer_states);
|
||||
}
|
||||
#[test]
|
||||
fn layouts_and_paperspace() {
|
||||
collection(collections::layouts(), collections::assert_layouts);
|
||||
}
|
||||
|
||||
fn with_dimension_styles(cases: Vec<Case>) {
|
||||
// Fixed-length extension lines (DIMFXL) were added in 2007. Older DWG records have no
|
||||
// slot for the inactive default length, and the bridge supplies zero on read.
|
||||
for case in cases {
|
||||
for version in Version::ALL {
|
||||
let mut expected = case.drawing.clone();
|
||||
if version < Version::R2007 {
|
||||
for style in &mut expected.dim_styles {
|
||||
style.fixed_ext_length = 0.0;
|
||||
}
|
||||
}
|
||||
super::check_version(&case.name, &case.drawing, &expected, version);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Native DWG Drawing → bytes → Drawing preservation, using the same cases and assertions as
|
||||
//! DXF. Every representable case runs at all six save versions and through two saves. R2000's
|
||||
//! lack of true color/transparency is a format limit, tested separately with explicit expected
|
||||
//! downgrades; it is not a normalization of modern output. TEXT's single elevation is also
|
||||
//! checked separately for non-coplanar inputs. Extended cases also specify handle-based name
|
||||
//! resolution, anonymous dimension-block names and legacy block/dimension-style limits
|
||||
//! explicitly. Other differences fail.
|
||||
//! Native-object conformance checks bypass one bridge direction, not the underlying codec.
|
||||
//!
|
||||
//! Run `cargo test -p cadcraft-io --test it roundtrip::dwg`; add `-- --ignored` to reproduce
|
||||
//! pending defects. No DWG bytes are imported from another application or kept as fixtures.
|
||||
|
||||
mod conformance;
|
||||
mod dimensions;
|
||||
mod entities;
|
||||
mod extended;
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Drawing, EntityKind, Transparency};
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
|
||||
use super::{cases, compare};
|
||||
|
||||
fn check(cases: Vec<cases::Case>) {
|
||||
for case in cases {
|
||||
assert!(
|
||||
!case.drawing.model.iter().any(|e| matches!(&e.kind, EntityKind::Text(t) if t.align_pt.is_some_and(|p| p.z != t.insert.z))),
|
||||
"{}: non-coplanar TEXT belongs in the explicit single-elevation test",
|
||||
case.name
|
||||
);
|
||||
let mut tested = 0;
|
||||
for version in Version::ALL {
|
||||
if version == Version::R2000 && needs_color_or_transparency(&case.drawing) {
|
||||
continue;
|
||||
}
|
||||
check_version(&case.name, &case.drawing, &case.drawing, version);
|
||||
tested += 1;
|
||||
}
|
||||
assert!(tested > 0, "{}: the version domain must not exclude a whole case", case.name);
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_color_or_transparency(d: &Drawing) -> bool {
|
||||
d.model.iter().any(|e| matches!(e.common.color, Color::True(_)) || e.common.transparency != Transparency::ByLayer)
|
||||
|| d.layers.iter().any(|l| matches!(l.color, Color::True(_)) || l.transparency != 0)
|
||||
}
|
||||
|
||||
fn check_version(case: &str, original: &Drawing, expected: &Drawing, version: Version) {
|
||||
check_version_with(case, original, expected, version, |_, _, _| {});
|
||||
}
|
||||
|
||||
fn check_version_with(case: &str, original: &Drawing, expected: &Drawing, version: Version, extra: impl Fn(&Drawing, &Drawing, &str)) {
|
||||
check_version_using(case, original, expected, version, |a, b, context| {
|
||||
compare::model(a, b, context);
|
||||
extra(a, b, context);
|
||||
});
|
||||
}
|
||||
|
||||
fn check_version_using(case: &str, original: &Drawing, expected: &Drawing, version: Version, verify: impl Fn(&Drawing, &Drawing, &str)) {
|
||||
let options = SaveOptions { version: Some(version), binary: false };
|
||||
let mut current = original.clone();
|
||||
for pass in 1..=2 {
|
||||
let context = format!("{case} · DWG {version} · pass={pass}");
|
||||
let bytes = write_with(¤t, "memory.dwg", &options).unwrap_or_else(|e| panic!("{context}: write: {e}"));
|
||||
assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version");
|
||||
let back = read(&bytes, "memory.dwg").unwrap_or_else(|e| panic!("{context}: read: {e}"));
|
||||
assert!(back.read_notes.is_empty(), "{context}: read notes: {:?}", back.read_notes);
|
||||
assert!(back.skipped_records.is_empty(), "{context}: skipped records: {:?}", back.skipped_records);
|
||||
verify(expected, &back, &context);
|
||||
if pass == 2 {
|
||||
verify(¤t, &back, &format!("{context} · resave stability"));
|
||||
}
|
||||
current = back;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Check multiline ATTRIB values, embedded geometry and legacy placement independently.
|
||||
//! Unchanged MTEXT rendering does not excuse corrupted attribute values or fallback TEXT
|
||||
//! justification (#2255). Full field preservation remains in `extended::multiline_attributes`;
|
||||
//! the legacy-placement test must reach its geometry assertion even while that check fails.
|
||||
//! ODA's DWG specification §20.4.4 maps the R2018+ embedded object to DXF group 101.
|
||||
|
||||
use super::super::cases::blocks;
|
||||
use cadcraft_doc::{Attrib, Drawing, EntityKind, Space};
|
||||
use cadcraft_io::{SaveOptions, Version};
|
||||
use cadcraft_render::{DisplayList, Options, build};
|
||||
|
||||
const EDITED_VALUE: &str = "edited {value}\nthird line";
|
||||
const EDITED_CONTENTS: &str = "edited \\{value\\}\\Pthird line";
|
||||
|
||||
/// The fixture's primary attribute, not its unrelated single-line control or its ATTDEF.
|
||||
fn primary(drawing: &Drawing) -> &Attrib {
|
||||
let mut attributes = drawing
|
||||
.model
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => Some(i.attribs.iter()),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.filter(|a| a.tag == "ID");
|
||||
let attribute = attributes.next().expect("primary ATTRIB must survive");
|
||||
assert!(attributes.next().is_none(), "primary ATTRIB must not be duplicated");
|
||||
attribute
|
||||
}
|
||||
|
||||
fn edit_primary(drawing: &mut Drawing, edit: impl Fn(&mut Attrib)) {
|
||||
let handles: Vec<_> = drawing.model.iter().map(|e| e.handle).collect();
|
||||
let mut changed = 0;
|
||||
for handle in handles {
|
||||
drawing.model.modify(handle, |e| {
|
||||
if let EntityKind::Insert(i) = &mut e.kind {
|
||||
for a in i.attribs.iter_mut().filter(|a| a.tag == "ID") {
|
||||
edit(a);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
assert_eq!(changed, 1, "edit exactly the primary ATTRIB");
|
||||
}
|
||||
|
||||
fn reopen(drawing: &Drawing, version: Version, binary: bool, context: &str) -> Drawing {
|
||||
let options = SaveOptions { version: Some(version), binary };
|
||||
let bytes = cadcraft_io::write_with(drawing, "attributes.dxf", &options).unwrap_or_else(|e| panic!("{context}: write: {e}"));
|
||||
assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding");
|
||||
let back = cadcraft_io::read(&bytes, "attributes.dxf").unwrap_or_else(|e| panic!("{context}: read: {e}"));
|
||||
assert_eq!(cadcraft_io::save_version(&back), version, "{context}: version");
|
||||
assert!(back.read_notes.is_empty(), "{context}: {:?}", back.read_notes);
|
||||
assert!(back.skipped_records.is_empty(), "{context}: {:?}", back.skipped_records);
|
||||
back
|
||||
}
|
||||
|
||||
fn geometry(drawing: &Drawing) -> DisplayList {
|
||||
build(drawing, &Space::Model, &Options::default())
|
||||
}
|
||||
|
||||
fn same_geometry(expected: &DisplayList, actual: &DisplayList, context: &str) {
|
||||
assert!(!expected.prims.is_empty(), "{context}: nonempty fixture");
|
||||
assert_eq!(expected.prims.len(), actual.prims.len(), "{context}: primitive count");
|
||||
for (a, b) in expected.prims.iter().zip(&actual.prims) {
|
||||
assert_eq!(a.kind, b.kind, "{context}: primitive kind");
|
||||
assert_eq!(expected.points(a), actual.points(b), "{context}: world-space geometry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Check saved data, not only the effective text drawn by `multiline_text()`. That method can
|
||||
/// hide a corrupt value/read_as pair or replace stale embedded contents from the plain value.
|
||||
fn saved_text(drawing: &Drawing, value: &str, contents: &str, context: &str) {
|
||||
let a = primary(drawing);
|
||||
assert_eq!(a.text.value, value, "{context}: attribute value");
|
||||
let m = a.multiline.as_ref().expect("embedded MTEXT must survive");
|
||||
assert_eq!(m.read_as, value, "{context}: read_as");
|
||||
assert_eq!(m.mtext.contents, contents, "{context}: raw embedded contents");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_geometry_and_value_edits_survive_two_saves() {
|
||||
for case in blocks::multiline_attributes() {
|
||||
let expected = geometry(&case.drawing);
|
||||
let mut edited = case.drawing.clone();
|
||||
edit_primary(&mut edited, |a| a.text.value = EDITED_VALUE.into());
|
||||
let edited_expected = geometry(&edited);
|
||||
assert_ne!(expected.verts, edited_expected.verts, "value edit must actually change the drawn text");
|
||||
for binary in [false, true] {
|
||||
let mut current = case.drawing.clone();
|
||||
for pass in 1..=2 {
|
||||
let context = format!("{} · R2018 · binary={binary} · pass={pass}", case.name);
|
||||
current = reopen(¤t, Version::R2018, binary, &context);
|
||||
saved_text(¤t, "first\nsecond", "first\\Psecond", &context);
|
||||
same_geometry(&expected, &geometry(¤t), &context);
|
||||
let mut edited = current.clone();
|
||||
edit_primary(&mut edited, |a| a.text.value = EDITED_VALUE.into());
|
||||
same_geometry(&edited_expected, &geometry(&edited), &format!("{context} · edit after reopening"));
|
||||
let context = format!("{context} · save after editing");
|
||||
let back = reopen(&edited, Version::R2018, binary, &context);
|
||||
saved_text(&back, EDITED_VALUE, EDITED_CONTENTS, &context);
|
||||
same_geometry(&edited_expected, &geometry(&back), &context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_text_checks_reject_corruption_hidden_by_rendering() {
|
||||
let mut source = blocks::multiline_attributes().remove(0).drawing;
|
||||
edit_primary(&mut source, |a| a.text.value = EDITED_VALUE.into());
|
||||
let back = reopen(&source, Version::R2018, true, "mutation control");
|
||||
saved_text(&back, EDITED_VALUE, EDITED_CONTENTS, "mutation control");
|
||||
for field in ["value", "read_as", "contents", "value-and-read_as"] {
|
||||
let mut corrupt = back.clone();
|
||||
edit_primary(&mut corrupt, |a| {
|
||||
let m = a.multiline.as_mut().unwrap();
|
||||
match field {
|
||||
"value" => a.text.value = "CORRUPTED VALUE".into(),
|
||||
"read_as" => m.read_as = "CORRUPTED VALUE".into(),
|
||||
"contents" => m.mtext.contents = "CORRUPTED CONTENTS".into(),
|
||||
"value-and-read_as" => {
|
||||
a.text.value = "CORRUPTED VALUE".into();
|
||||
m.read_as.clone_from(&a.text.value);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
});
|
||||
if field == "value-and-read_as" {
|
||||
same_geometry(&geometry(&back), &geometry(&corrupt), "corruption invisible to geometry");
|
||||
}
|
||||
assert!(std::panic::catch_unwind(|| saved_text(&corrupt, EDITED_VALUE, EDITED_CONTENTS, field)).is_err(), "must reject {field}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Confirmed DXF placement loss (#2255): R2018 save changes subsequent legacy ATTRIB placement; existing reader fix #1942"]
|
||||
fn legacy_placement_is_independent_of_an_intermediate_modern_save() {
|
||||
for case in blocks::multiline_attributes() {
|
||||
for version in [Version::R2000, Version::R2004] {
|
||||
for binary in [false, true] {
|
||||
let legacy_context = format!("{} · legacy={version} · binary={binary}", case.name);
|
||||
let direct = reopen(&case.drawing, version, binary, &legacy_context);
|
||||
let a = primary(&direct);
|
||||
assert!(a.multiline.is_none(), "{legacy_context}: single-line fallback required");
|
||||
assert_eq!(a.text.value, "first second", "{legacy_context}: fallback value");
|
||||
let expected = geometry(&direct);
|
||||
for modern_binary in [false, true] {
|
||||
let mut current = case.drawing.clone();
|
||||
for pass in 1..=2 {
|
||||
let context = format!("{legacy_context} · intermediate=R2018 · binary={modern_binary} · pass={pass}");
|
||||
current = reopen(¤t, Version::R2018, modern_binary, &context);
|
||||
let indirect = reopen(¤t, version, binary, &context);
|
||||
let a = primary(&indirect);
|
||||
assert!(a.multiline.is_none(), "{context}: single-line fallback required");
|
||||
assert_eq!(a.text.value, "first second", "{context}: fallback value");
|
||||
same_geometry(&expected, &geometry(&indirect), &context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Independent input and output contracts for common LINE/CIRCLE/TEXT properties. Read fixtures
|
||||
//! are handwritten DXF, never writer output; write expectations are literal groups, never a
|
||||
//! reread Drawing. Absence, explicit zero and inheritance are distinct. Pending tests localize
|
||||
//! the existing extrusion and ByBlock plot-style losses without accepting them as correct.
|
||||
//! References: https://ezdxf.readthedocs.io/en/stable/dxfentities/dxfgfx.html
|
||||
//! https://ezdxf.readthedocs.io/en/stable/concepts/true_color.html
|
||||
//! https://ezdxf.readthedocs.io/en/stable/concepts/transparency.html
|
||||
|
||||
use cadcraft_color::{Color, Rgb};
|
||||
use cadcraft_doc::{Common, Drawing, EntityKind, Layer, Lineweight, Transparency};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
|
||||
fn fixture(properties: &str) -> Drawing {
|
||||
let records = [
|
||||
("LINE", "AcDbLine", "10\n1\n20\n2\n30\n3\n11\n4\n21\n5\n31\n6\n"),
|
||||
("CIRCLE", "AcDbCircle", "10\n7\n20\n8\n30\n9\n40\n2.5\n"),
|
||||
("TEXT", "AcDbText", "10\n3\n20\n4\n40\n2.5\n1\nfixture\n"),
|
||||
];
|
||||
let mut bytes = String::from("0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1032\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n");
|
||||
for (kind, subclass, geometry) in records {
|
||||
bytes.push_str(&format!("0\n{kind}\n100\nAcDbEntity\n{properties}100\n{subclass}\n{geometry}"));
|
||||
}
|
||||
bytes.push_str("0\nENDSEC\n0\nEOF\n");
|
||||
read(bytes.as_bytes(), "fixture.dxf").unwrap()
|
||||
}
|
||||
|
||||
fn assert_read(properties: &str, expected: &Common) {
|
||||
let drawing = fixture(properties);
|
||||
assert_eq!(drawing.model.len(), 3);
|
||||
assert!(drawing.skipped_records.is_empty());
|
||||
for entity in drawing.model.iter() {
|
||||
assert_eq!(&entity.common, expected, "{} with groups {properties:?}", entity.kind.type_name());
|
||||
}
|
||||
}
|
||||
|
||||
fn output(common: &Common, version: Version, binary: bool) -> Vec<(String, Vec<Tag>)> {
|
||||
let mut drawing = super::conformance::model();
|
||||
if drawing.layer(&common.layer).is_none() {
|
||||
drawing.layers.push(Layer::new(&common.layer));
|
||||
}
|
||||
let handles: Vec<_> = drawing.model.iter().map(|e| e.handle).collect();
|
||||
for handle in handles {
|
||||
drawing.model.modify(handle, |e| e.common = common.clone());
|
||||
}
|
||||
let bytes = write_with(&drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&bytes).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags);
|
||||
assert_eq!(entities.iter().map(|(kind, _)| kind.as_str()).collect::<Vec<_>>(), ["LINE", "CIRCLE", "TEXT"]);
|
||||
entities
|
||||
}
|
||||
|
||||
fn assert_write(common: Common, present: &[Tag], absent: &[i32]) {
|
||||
for version in Version::ALL {
|
||||
for binary in [false, true] {
|
||||
for (kind, groups) in output(&common, version, binary) {
|
||||
let context = format!("{version} binary={binary} {kind} {common:?}");
|
||||
for tag in present {
|
||||
assert_eq!(groups.iter().filter(|t| t.code == tag.code).collect::<Vec<_>>(), [tag], "{context}: group {}", tag.code);
|
||||
}
|
||||
for code in absent {
|
||||
assert!(!groups.iter().any(|t| t.code == *code), "{context}: group {code} should be omitted");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_optional_groups_and_explicit_defaults() {
|
||||
assert_read("", &Common::default());
|
||||
assert_read("8\n0\n6\nByLayer\n62\n256\n370\n-1\n48\n1\n60\n0\n39\n0\n210\n0\n220\n0\n230\n1\n", &Common::default());
|
||||
// Omission is this writer's convention, not a requirement that other DXF writers omit them.
|
||||
assert_write(Common::default(), &[Tag::s(8, "0")], &[6, 62, 370, 48, 60, 39, 420, 440, 210, 220, 230]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_linetype_scale_visibility_and_thickness_have_independent_groups() {
|
||||
let common =
|
||||
Common { layer: "Fixture".into(), linetype: "Continuous".into(), ltscale: 2.75, visible: false, thickness: -1.25, ..Common::default() };
|
||||
assert_read("8\nFixture\n6\nContinuous\n48\n2.75\n60\n1\n39\n-1.25\n", &common);
|
||||
assert_write(common, &[Tag::s(8, "Fixture"), Tag::s(6, "Continuous"), Tag::f(48, 2.75), Tag::i(60, 1), Tag::f(39, -1.25)], &[62, 420, 440]);
|
||||
assert_read("6\nByBlock\n", &Common { linetype: "ByBlock".into(), ..Common::default() });
|
||||
assert_write(Common { linetype: "ByBlock".into(), ..Common::default() }, &[Tag::s(6, "ByBlock")], &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lineweight_distinguishes_logical_modes_and_explicit_zero() {
|
||||
for (value, lineweight) in [
|
||||
(-1, Lineweight::ByLayer),
|
||||
(-2, Lineweight::ByBlock),
|
||||
(-3, Lineweight::Default),
|
||||
(0, Lineweight::Mm100(0)),
|
||||
(53, Lineweight::Mm100(53)),
|
||||
(211, Lineweight::Mm100(211)),
|
||||
] {
|
||||
let common = Common { lineweight, ..Common::default() };
|
||||
assert_read(&format!("370\n{value}\n"), &common);
|
||||
if value == -1 {
|
||||
assert_write(common, &[], &[370]);
|
||||
} else {
|
||||
assert_write(common, &[Tag::i(370, value)], &[]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aci_color_distinguishes_inheritance_and_explicit_color() {
|
||||
for (value, color) in
|
||||
[(256, Color::ByLayer), (0, Color::ByBlock)].into_iter().chain((1..=255).map(|index| (i64::from(index), Color::Index(index))))
|
||||
{
|
||||
let common = Common { color, ..Common::default() };
|
||||
assert_read(&format!("62\n{value}\n"), &common);
|
||||
if value == 256 {
|
||||
assert_write(common, &[], &[62, 420]);
|
||||
} else {
|
||||
assert_write(common, &[Tag::i(62, value)], &[420]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn true_color_overrides_aci_in_either_tag_order_including_black() {
|
||||
// 0x12_34_56 = 1,193,046. The black value 0 must not be mistaken for an absent group.
|
||||
for (value, rgb) in [(1_193_046, Rgb(0x12, 0x34, 0x56)), (0, Rgb(0, 0, 0))] {
|
||||
let common = Common { color: Color::True(rgb), ..Common::default() };
|
||||
for aci in [0, 1, 256] {
|
||||
assert_read(&format!("62\n{aci}\n420\n{value}\n"), &common);
|
||||
assert_read(&format!("420\n{value}\n62\n{aci}\n"), &common);
|
||||
}
|
||||
assert_write(common, &[Tag::i(420, value)], &[]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_distinguishes_inheritance_and_explicit_opacity() {
|
||||
assert_read("", &Common::default());
|
||||
assert_write(Common::default(), &[], &[440]);
|
||||
// High byte: 01 = ByBlock, 02 = explicit alpha. Low byte: opacity, not transparency.
|
||||
// Independent integer oracle: floor(255 * (1 - p/100)) = 255 - ceil(255*p/100).
|
||||
// No production alpha conversion is used. Check every supported integer percentage.
|
||||
for (raw, transparency) in std::iter::once((0x0100_0000, Transparency::ByBlock)).chain((0..=90).map(|percent| {
|
||||
let opacity = 255 - (255 * i64::from(percent) + 99) / 100;
|
||||
(0x0200_0000 + opacity, Transparency::Percent(percent))
|
||||
})) {
|
||||
let common = Common { transparency, ..Common::default() };
|
||||
assert_read(&format!("440\n{raw}\n"), &common);
|
||||
assert_write(common, &[Tag::i(440, raw)], &[]);
|
||||
}
|
||||
// Another writer may round up instead; both neighboring alpha values map to the same
|
||||
// integer percentage. This is a read expectation, not normalization of writer output.
|
||||
assert_read("440\n33554560\n", &Common { transparency: Transparency::Percent(50), ..Common::default() });
|
||||
assert_read("440\n33554458\n", &Common { transparency: Transparency::Percent(90), ..Common::default() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_line_extrusion_without_changing_world_space_endpoints() {
|
||||
for (groups, expected) in [("210\n0\n220\n0\n230\n-1\n", Vec3::new(0.0, 0.0, -1.0)), ("220\n1\n230\n0\n", Vec3::new(0.0, 1.0, 0.0))] {
|
||||
let drawing = fixture(groups);
|
||||
let line = drawing.model.iter().next().unwrap();
|
||||
assert_eq!(line.common.extrusion, expected);
|
||||
let EntityKind::Line(geometry) = &line.kind else { panic!("expected LINE") };
|
||||
assert_eq!(geometry.a, Vec3::new(1.0, 2.0, 3.0));
|
||||
assert_eq!(geometry.b, Vec3::new(4.0, 5.0, 6.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Known writer loss: a non-default extrusion is omitted; the independent LINE read test passes"]
|
||||
fn writes_nondefault_extrusion_groups() {
|
||||
// X/Y may be omitted at zero, but Z=-1 must not disappear into the default Z=1.
|
||||
assert_write(Common { extrusion: Vec3::new(0.0, 0.0, -1.0), ..Common::default() }, &[Tag::f(230, -1.0)], &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Known writer loss: ByLayer and ByBlock plot styles produce identical files"]
|
||||
fn writer_must_distinguish_bylayer_from_byblock_plot_style() {
|
||||
// The available group-380 HEADER enum is not sufficient evidence for a universal entity
|
||||
// encoding; do not prescribe an unverified tag here. Identical complete outputs prove the
|
||||
// loss is already in the writer, independently of any reader or representation choice.
|
||||
let by_layer = super::conformance::model();
|
||||
let mut by_block = by_layer.clone();
|
||||
let handles: Vec<_> = by_block.model.iter().map(|e| e.handle).collect();
|
||||
for handle in handles {
|
||||
by_block.model.modify(handle, |e| e.common.plot_style = "ByBlock".into());
|
||||
}
|
||||
for version in Version::ALL {
|
||||
for binary in [false, true] {
|
||||
let options = SaveOptions { version: Some(version), binary };
|
||||
let a = write_with(&by_layer, "memory.dxf", &options).unwrap();
|
||||
let b = write_with(&by_block, "memory.dxf", &options).unwrap();
|
||||
assert!(a != b, "{version} binary={binary}: plot style is not encoded anywhere in the file");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Independent synthetic entity records prevent matching reader/writer mistakes from passing
|
||||
//! the round-trip suite. Only public DXF group meanings are used; no reference-app files or assets.
|
||||
//! Entity semantics: https://ezdxf.readthedocs.io/en/stable/dxfentities/text.html
|
||||
//! and https://ezdxf.readthedocs.io/en/stable/dxfentities/line.html
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Circle, Common, Drawing, Entity, EntityKind, HAlign, Handle, Line, Space, Text, VAlign};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
|
||||
use super::compare;
|
||||
|
||||
const ENTITIES: &[u8] = b"0\nSECTION\n2\nENTITIES\n\
|
||||
0\nLINE\n5\n100\n8\n0\n62\n3\n60\n1\n39\n-2.5\n10\n1.25\n20\n-2.5\n30\n3.75\n11\n-4.5\n21\n5.25\n31\n-6.75\n\
|
||||
0\nCIRCLE\n5\n101\n8\n0\n10\n7.25\n20\n-8.5\n30\n9.75\n40\n2.125\n\
|
||||
0\nTEXT\n5\n102\n8\n0\n10\n-3.25\n20\n4.5\n30\n1.75\n40\n2.5\n1\nfixture text\n50\n30\n41\n1.25\n51\n-15\n7\nStandard\n\
|
||||
0\nENDSEC\n0\nEOF\n";
|
||||
|
||||
pub(super) fn model() -> Drawing {
|
||||
let mut d = Drawing::new_metric();
|
||||
d.add(
|
||||
&Space::Model,
|
||||
Common { color: Color::Index(3), visible: false, thickness: -2.5, ..Common::default() },
|
||||
EntityKind::Line(Line { a: Vec3::new(1.25, -2.5, 3.75), b: Vec3::new(-4.5, 5.25, -6.75) }),
|
||||
)
|
||||
.unwrap();
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Circle(Circle { center: Vec3::new(7.25, -8.5, 9.75), radius: 2.125 })).unwrap();
|
||||
d.add(
|
||||
&Space::Model,
|
||||
Common::default(),
|
||||
EntityKind::Text(Text {
|
||||
insert: Vec3::new(-3.25, 4.5, 1.75),
|
||||
align_pt: None,
|
||||
height: 2.5,
|
||||
value: "fixture text".into(),
|
||||
rotation: 30_f64.to_radians(),
|
||||
width_factor: 1.25,
|
||||
oblique: -15_f64.to_radians(),
|
||||
style: "Standard".into(),
|
||||
halign: HAlign::Left,
|
||||
valign: VAlign::Baseline,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_independent_line_circle_and_text_records() {
|
||||
let back = read(ENTITIES, "fixture.dxf").unwrap();
|
||||
compare::model(&model(), &back, "independent entity fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_standard_entity_groups_not_just_self_readable_data() {
|
||||
let expected = [
|
||||
(
|
||||
"LINE",
|
||||
vec![
|
||||
Tag::s(5, "100"),
|
||||
Tag::i(62, 3),
|
||||
Tag::i(60, 1),
|
||||
Tag::f(39, -2.5),
|
||||
Tag::f(10, 1.25),
|
||||
Tag::f(20, -2.5),
|
||||
Tag::f(30, 3.75),
|
||||
Tag::f(11, -4.5),
|
||||
Tag::f(21, 5.25),
|
||||
Tag::f(31, -6.75),
|
||||
],
|
||||
),
|
||||
("CIRCLE", vec![Tag::s(5, "101"), Tag::f(10, 7.25), Tag::f(20, -8.5), Tag::f(30, 9.75), Tag::f(40, 2.125)]),
|
||||
(
|
||||
"TEXT",
|
||||
vec![
|
||||
Tag::s(5, "102"),
|
||||
Tag::f(10, -3.25),
|
||||
Tag::f(20, 4.5),
|
||||
Tag::f(30, 1.75),
|
||||
Tag::f(40, 2.5),
|
||||
Tag::s(1, "fixture text"),
|
||||
Tag::f(41, 1.25),
|
||||
Tag::s(7, "Standard"),
|
||||
],
|
||||
),
|
||||
];
|
||||
for version in Version::ALL {
|
||||
for binary in [false, true] {
|
||||
let bytes = write_with(&model(), "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&bytes).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags);
|
||||
assert_eq!(entities.len(), expected.len());
|
||||
for ((kind, groups), (expected_kind, expected_groups)) in entities.iter().zip(&expected) {
|
||||
let context = format!("{version} binary={binary} {expected_kind}");
|
||||
assert_eq!(kind, expected_kind, "{context}");
|
||||
for tag in expected_groups {
|
||||
let values: Vec<_> = groups.iter().filter(|t| t.code == tag.code).collect();
|
||||
assert_eq!(values, [tag], "{context}: group {}", tag.code);
|
||||
}
|
||||
if kind == "TEXT" {
|
||||
for (code, degrees) in [(50, 30.0), (51, -15.0)] {
|
||||
let values: Vec<_> = groups.iter().filter(|t| t.code == code).collect();
|
||||
assert_eq!(values.len(), 1, "{context}: group {code}");
|
||||
assert!((values[0].f64() - degrees).abs() < 1e-13, "{context}: group {code} is in degrees");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_text_alignment_codes_independently_of_the_writer() {
|
||||
let horizontal = [(0, HAlign::Left), (1, HAlign::Center), (2, HAlign::Right), (3, HAlign::Aligned), (4, HAlign::Middle), (5, HAlign::Fit)];
|
||||
let vertical = [(0, VAlign::Baseline), (1, VAlign::Bottom), (2, VAlign::Middle), (3, VAlign::Top)];
|
||||
for (hcode, halign) in horizontal {
|
||||
for (vcode, valign) in vertical {
|
||||
if hcode >= 3 && vcode != 0 {
|
||||
continue;
|
||||
}
|
||||
let bytes = format!(
|
||||
"0\nSECTION\n2\nENTITIES\n0\nTEXT\n10\n3\n20\n4\n30\n5\n40\n2.5\n1\nA\n72\n{hcode}\n73\n{vcode}\n11\n6\n21\n7\n31\n8\n0\nENDSEC\n0\nEOF\n"
|
||||
);
|
||||
let back = read(bytes.as_bytes(), "fixture.dxf").unwrap();
|
||||
let EntityKind::Text(text) = &back.model.iter().next().unwrap().kind else { panic!("expected TEXT") };
|
||||
assert_eq!((text.halign, text.valign), (halign, valign), "72={hcode}, 73={vcode}");
|
||||
assert_eq!(text.insert, Vec3::new(3.0, 4.0, 5.0));
|
||||
let expected_point = if hcode == 0 && vcode == 0 { None } else { Some(Vec3::new(6.0, 7.0, 8.0)) };
|
||||
assert_eq!(text.align_pt, expected_point, "72={hcode}, 73={vcode}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Odd values above 2^53, above i64::MAX and near u64::MAX expose floating-point coercion,
|
||||
// signed conversion and truncation. Literal hex and numeric values are independent expectations.
|
||||
const LARGE_HANDLES: [(u64, &str); 3] =
|
||||
[(9_007_199_254_740_993, "20000000000001"), (9_223_372_036_854_775_809, "8000000000000001"), (18_446_744_073_709_486_081, "FFFFFFFFFFFF0001")];
|
||||
|
||||
#[test]
|
||||
fn reads_large_literal_hex_handles_without_numeric_coercion() {
|
||||
let mut fixture = String::from("0\nSECTION\n2\nENTITIES\n");
|
||||
for (_, hex) in LARGE_HANDLES {
|
||||
fixture.push_str(&format!("0\nLINE\n5\n{hex}\n8\n0\n10\n0\n20\n0\n11\n1\n21\n2\n"));
|
||||
}
|
||||
fixture.push_str("0\nENDSEC\n0\nEOF\n");
|
||||
let back = read(fixture.as_bytes(), "fixture.dxf").unwrap();
|
||||
assert!(back.read_notes.is_empty());
|
||||
assert!(back.skipped_records.is_empty());
|
||||
assert_eq!(back.model.iter().map(|e| e.handle.0).collect::<Vec<_>>(), LARGE_HANDLES.map(|(numeric, _)| numeric));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_large_handles_as_exact_hex_groups() {
|
||||
let mut drawing = Drawing::new_metric();
|
||||
for (numeric, _) in LARGE_HANDLES {
|
||||
drawing.model.push(Entity::new(Handle(numeric), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 2.0, 0.0) })));
|
||||
}
|
||||
drawing.handseed = 0xffff_ffff_ffff_1000;
|
||||
for version in Version::ALL {
|
||||
for binary in [false, true] {
|
||||
let bytes = write_with(&drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&bytes).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags);
|
||||
assert_eq!(entities.len(), LARGE_HANDLES.len());
|
||||
for ((kind, groups), (_, hex)) in entities.iter().zip(LARGE_HANDLES) {
|
||||
assert_eq!(kind, "LINE");
|
||||
assert_eq!(groups.iter().filter(|t| t.code == 5).collect::<Vec<_>>(), [&Tag::s(5, hex)], "{version} binary={binary} handle={hex}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_baseline_text_ignores_the_unused_second_alignment_point() {
|
||||
// DXF TEXT groups 11/21/31 are meaningful only when 72 or 73 is nonzero. The model
|
||||
// intentionally represents the unused point as None, not Some(Vec3::ZERO).
|
||||
let bytes = b"0\nSECTION\n2\nENTITIES\n0\nTEXT\n10\n3\n20\n4\n40\n2.5\n1\nA\n72\n0\n73\n0\n11\n0\n21\n0\n31\n0\n0\nENDSEC\n0\nEOF\n";
|
||||
let back = read(bytes, "fixture.dxf").unwrap();
|
||||
let EntityKind::Text(text) = &back.model.iter().next().unwrap().kind else { panic!("expected TEXT") };
|
||||
assert_eq!(text.align_pt, None);
|
||||
assert_eq!(text.insert, Vec3::new(3.0, 4.0, 0.0));
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! DXF runs every shared input family in both encodings and all writable versions. Keep known
|
||||
//! failures isolated from other property sweeps; the comparison contract is never weakened.
|
||||
|
||||
use super::super::cases;
|
||||
|
||||
fn check(cases: Vec<cases::Case>) {
|
||||
for case in cases {
|
||||
super::check(&case.name, &case.drawing);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_model_and_defaults() {
|
||||
check(cases::empty_and_defaults());
|
||||
}
|
||||
#[test]
|
||||
fn line_endpoints_and_circle_centers_radii() {
|
||||
check(cases::geometry());
|
||||
}
|
||||
#[test]
|
||||
fn layers() {
|
||||
check(cases::layers());
|
||||
}
|
||||
#[test]
|
||||
fn layer_inherited_linetypes() {
|
||||
check(cases::layer_linetypes());
|
||||
}
|
||||
#[test]
|
||||
fn colors() {
|
||||
check(cases::colors());
|
||||
}
|
||||
#[test]
|
||||
fn linetypes() {
|
||||
check(cases::linetypes());
|
||||
}
|
||||
#[test]
|
||||
fn lineweights() {
|
||||
check(cases::lineweights());
|
||||
}
|
||||
#[test]
|
||||
fn linetype_scales() {
|
||||
check(cases::linetype_scales());
|
||||
}
|
||||
#[test]
|
||||
fn transparencies() {
|
||||
check(cases::transparencies());
|
||||
}
|
||||
#[test]
|
||||
fn visibility() {
|
||||
check(cases::visibility());
|
||||
}
|
||||
#[test]
|
||||
fn thicknesses() {
|
||||
check(cases::thicknesses());
|
||||
}
|
||||
#[test]
|
||||
fn plot_styles() {
|
||||
check(cases::plot_styles());
|
||||
}
|
||||
#[test]
|
||||
fn foreign_xdata() {
|
||||
check(cases::foreign_xdata());
|
||||
}
|
||||
#[test]
|
||||
fn common_property_interactions() {
|
||||
check(cases::common_interactions("ByLayer"));
|
||||
check(cases::common_interactions("Review"));
|
||||
}
|
||||
#[test]
|
||||
fn text_values() {
|
||||
check(cases::text_values());
|
||||
}
|
||||
#[test]
|
||||
fn text_literal_escapes() {
|
||||
check(cases::text_literal_escapes());
|
||||
}
|
||||
#[test]
|
||||
fn text_heights() {
|
||||
check(cases::text_heights());
|
||||
}
|
||||
#[test]
|
||||
fn text_rotations() {
|
||||
check(cases::text_rotations());
|
||||
}
|
||||
#[test]
|
||||
fn text_widths() {
|
||||
check(cases::text_widths());
|
||||
}
|
||||
#[test]
|
||||
fn text_obliques() {
|
||||
check(cases::text_obliques());
|
||||
}
|
||||
#[test]
|
||||
fn text_styles() {
|
||||
check(cases::text_styles());
|
||||
}
|
||||
#[test]
|
||||
fn annotation_scales() {
|
||||
check(cases::annotation_scales());
|
||||
}
|
||||
#[test]
|
||||
fn text_alignment_with_points() {
|
||||
check(cases::coplanar_text_alignment());
|
||||
check(cases::noncoplanar_text_alignment());
|
||||
}
|
||||
#[test]
|
||||
fn text_alignment_without_points() {
|
||||
check(cases::text_alignment(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Known writer loss: ByBlock plot style becomes ByLayer on the first R2000 ASCII save"]
|
||||
fn plot_style_byblock_is_preserved() {
|
||||
check(cases::byblock_plot_style());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Known writer loss: groups 210/220/230 are omitted; a LINE's normal becomes (0,0,1)"]
|
||||
fn nondefault_extrusion_is_preserved() {
|
||||
check(cases::extrusions());
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
//! Extended DXF property families. Pending losses stay separate from passing families;
|
||||
//! version domains are explicit for Lock position, HELIX/MULTILEADER (2007) and the native
|
||||
//! multiline-attribute representation (2018).
|
||||
|
||||
use super::super::cases::{Case, blocks, complex, geometry, properties};
|
||||
use super::super::collections;
|
||||
use cadcraft_io::Version;
|
||||
|
||||
fn check(cases: Vec<Case>) {
|
||||
assert!(!cases.is_empty());
|
||||
for case in cases {
|
||||
super::check(&case.name, &case.drawing);
|
||||
}
|
||||
}
|
||||
fn since_2007(cases: Vec<Case>) {
|
||||
assert!(!cases.is_empty());
|
||||
for case in cases {
|
||||
for version in Version::ALL.into_iter().filter(|v| *v >= Version::R2007) {
|
||||
super::check_version(&case.name, &case.drawing, &case.drawing, version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_case_layers() {
|
||||
check(properties::mixed_case_references("layer"));
|
||||
}
|
||||
#[test]
|
||||
fn mixed_case_linetypes() {
|
||||
check(properties::mixed_case_references("linetype"));
|
||||
}
|
||||
#[test]
|
||||
fn mixed_case_styles() {
|
||||
check(properties::mixed_case_references("style"));
|
||||
}
|
||||
#[test]
|
||||
fn layer_flags() {
|
||||
check(properties::layer_flags());
|
||||
}
|
||||
#[test]
|
||||
fn layer_appearance() {
|
||||
check(properties::layer_appearance());
|
||||
}
|
||||
#[test]
|
||||
fn layer_plot_styles() {
|
||||
check(properties::layer_plot_styles());
|
||||
}
|
||||
#[test]
|
||||
fn text_style_metrics() {
|
||||
check(properties::text_style_metrics());
|
||||
}
|
||||
#[test]
|
||||
fn text_style_flags() {
|
||||
check(properties::text_style_flags());
|
||||
}
|
||||
#[test]
|
||||
fn text_style_fonts() {
|
||||
check(properties::text_style_fonts());
|
||||
}
|
||||
#[test]
|
||||
fn linetype_text() {
|
||||
check(properties::complex_linetypes(false));
|
||||
}
|
||||
#[test]
|
||||
fn linetype_shapes() {
|
||||
check(properties::complex_linetypes(true));
|
||||
}
|
||||
#[test]
|
||||
fn xdata_lists() {
|
||||
check(properties::xdata_lists());
|
||||
}
|
||||
#[test]
|
||||
fn xdata_points() {
|
||||
check(properties::xdata_points());
|
||||
}
|
||||
#[test]
|
||||
fn xdata_handles() {
|
||||
check(properties::xdata_handles());
|
||||
}
|
||||
#[test]
|
||||
fn handles_and_order() {
|
||||
check(properties::handles_and_order());
|
||||
}
|
||||
#[test]
|
||||
fn handles_near_integer_limits() {
|
||||
check(properties::handles_near_integer_limits());
|
||||
}
|
||||
#[test]
|
||||
fn degenerate_lines() {
|
||||
check(properties::degenerate_geometry());
|
||||
}
|
||||
#[test]
|
||||
fn layer_inheritance_overrides() {
|
||||
check(properties::layer_inheritance_overrides());
|
||||
}
|
||||
#[test]
|
||||
fn alignment_style_interactions() {
|
||||
check(properties::alignment_style_interactions());
|
||||
}
|
||||
#[test]
|
||||
fn nested_attributes() {
|
||||
check(blocks::nested_attributes());
|
||||
}
|
||||
#[test]
|
||||
fn text_interactions() {
|
||||
check(properties::text_interactions());
|
||||
}
|
||||
#[test]
|
||||
fn long_text() {
|
||||
check(properties::long_text());
|
||||
}
|
||||
#[test]
|
||||
fn arcs() {
|
||||
check(geometry::arcs());
|
||||
}
|
||||
#[test]
|
||||
fn ellipses() {
|
||||
check(geometry::ellipses());
|
||||
}
|
||||
#[test]
|
||||
fn polyline_constant_widths() {
|
||||
check(geometry::polylines(false));
|
||||
}
|
||||
#[test]
|
||||
fn polyline_variable_widths() {
|
||||
check(geometry::polylines(true));
|
||||
}
|
||||
#[test]
|
||||
fn polylines_3d() {
|
||||
check(geometry::polylines_3d());
|
||||
}
|
||||
#[test]
|
||||
fn splines() {
|
||||
check(geometry::splines(false));
|
||||
}
|
||||
#[test]
|
||||
fn rational_splines() {
|
||||
check(geometry::splines(true));
|
||||
}
|
||||
#[test]
|
||||
fn fit_splines() {
|
||||
check(geometry::spline_fit(false));
|
||||
check(geometry::spline_fit(true));
|
||||
}
|
||||
#[test]
|
||||
fn points_and_rays() {
|
||||
check(geometry::points_and_rays());
|
||||
}
|
||||
#[test]
|
||||
fn faces() {
|
||||
check(geometry::faces());
|
||||
}
|
||||
#[test]
|
||||
fn mtext_placement() {
|
||||
check(geometry::mtext_placement());
|
||||
}
|
||||
#[test]
|
||||
fn mtext_spacing() {
|
||||
check(geometry::mtext_spacing());
|
||||
}
|
||||
#[test]
|
||||
fn mtext_contents() {
|
||||
check(geometry::mtext_contents());
|
||||
check(geometry::mtext_carets());
|
||||
}
|
||||
#[test]
|
||||
fn mtext_long() {
|
||||
check(geometry::mtext_long());
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Confirmed DXF loss: MTEXT column group 48 is also read as Common.ltscale"]
|
||||
fn mtext_columns() {
|
||||
check(geometry::mtext_columns());
|
||||
}
|
||||
#[test]
|
||||
fn leaders() {
|
||||
check(geometry::leaders(false));
|
||||
}
|
||||
#[test]
|
||||
fn hatches() {
|
||||
check(geometry::hatches());
|
||||
}
|
||||
#[test]
|
||||
fn tables() {
|
||||
check(geometry::tables());
|
||||
}
|
||||
#[test]
|
||||
fn images() {
|
||||
check(geometry::images());
|
||||
}
|
||||
#[test]
|
||||
fn wipeouts() {
|
||||
check(geometry::wipeouts(true));
|
||||
}
|
||||
#[test]
|
||||
fn wipeout_implicit_closure() {
|
||||
// The format closes the stored boundary explicitly; this is not an extra editable corner.
|
||||
// Keep grip behavior separate from this serialization contract (#2256).
|
||||
for (case, expected) in geometry::wipeouts(false).into_iter().zip(geometry::wipeouts(true)) {
|
||||
for version in Version::ALL {
|
||||
super::check_version(&case.name, &case.drawing, &expected.drawing, version);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Confirmed DXF loss: LEADER vertex Z coordinates become zero"]
|
||||
fn leaders_3d() {
|
||||
check(geometry::leaders(true));
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Confirmed DXF loss: ATTRIB constant flag becomes false"]
|
||||
fn constant_attributes() {
|
||||
check(blocks::attributes("constant"));
|
||||
}
|
||||
#[test]
|
||||
fn block_transformations() {
|
||||
check(blocks::transformations());
|
||||
}
|
||||
#[test]
|
||||
fn block_arrays() {
|
||||
check(blocks::arrays());
|
||||
}
|
||||
#[test]
|
||||
fn block_metadata() {
|
||||
check(blocks::metadata());
|
||||
}
|
||||
#[test]
|
||||
fn nested_blocks() {
|
||||
check(blocks::nested(false));
|
||||
}
|
||||
#[test]
|
||||
fn cyclic_blocks() {
|
||||
check(blocks::nested(true));
|
||||
}
|
||||
#[test]
|
||||
fn unreferenced_blocks() {
|
||||
check(blocks::unreferenced());
|
||||
}
|
||||
#[test]
|
||||
fn attribute_flags() {
|
||||
check(blocks::attributes("flags"));
|
||||
}
|
||||
#[test]
|
||||
fn attribute_modes() {
|
||||
check(blocks::attributes("modes"));
|
||||
}
|
||||
#[test]
|
||||
fn attribute_lock() {
|
||||
since_2007(blocks::attributes("lock"));
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Confirmed DXF read loss (#2255): multiline header changes Left to Center and synthesizes align_pt; existing fix #1942"]
|
||||
fn multiline_attributes() {
|
||||
for case in blocks::multiline_attributes() {
|
||||
super::check_version(&case.name, &case.drawing, &case.drawing, Version::R2018);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn attribute_overrides() {
|
||||
check(blocks::attributes("overrides"));
|
||||
}
|
||||
#[test]
|
||||
fn helices() {
|
||||
since_2007(complex::helices());
|
||||
}
|
||||
#[test]
|
||||
fn mlines() {
|
||||
check(complex::mlines());
|
||||
}
|
||||
#[test]
|
||||
fn mleaders() {
|
||||
since_2007(complex::mleaders());
|
||||
}
|
||||
#[test]
|
||||
fn regions() {
|
||||
check(complex::regions());
|
||||
}
|
||||
#[test]
|
||||
fn tolerances() {
|
||||
check(complex::tolerances());
|
||||
}
|
||||
#[test]
|
||||
fn dimensions() {
|
||||
check(complex::dimensions());
|
||||
}
|
||||
|
||||
fn collection(case: Case, verify: fn(&cadcraft_doc::Drawing, &cadcraft_doc::Drawing, &str)) {
|
||||
for version in Version::ALL {
|
||||
super::check_version_with(&case.name, &case.drawing, &case.drawing, version, verify);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn header_variables() {
|
||||
collection(collections::headers(), collections::assert_headers);
|
||||
for key in ["CLAYER", "TEXTSTYLE"] {
|
||||
collection(collections::named_header_reference(key), collections::assert_headers);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn unreferenced_tables() {
|
||||
collection(collections::symbol_tables(), collections::assert_symbol_tables);
|
||||
}
|
||||
#[test]
|
||||
fn foreign_dictionary_records() {
|
||||
collection(collections::foreign_records(), collections::assert_foreign_records);
|
||||
}
|
||||
#[test]
|
||||
fn unknown_entity_payload_and_references() {
|
||||
for version in Version::ALL {
|
||||
for binary in [false, true] {
|
||||
super::super::unknown::check("memory.dxf", version, binary);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn views_and_ucs() {
|
||||
collection(collections::views_and_ucs(), collections::assert_views_and_ucs);
|
||||
}
|
||||
#[test]
|
||||
fn tiled_viewports() {
|
||||
collection(collections::vports(), collections::assert_vports);
|
||||
}
|
||||
#[test]
|
||||
fn groups() {
|
||||
collection(collections::groups(), collections::assert_groups);
|
||||
}
|
||||
#[test]
|
||||
fn layer_states() {
|
||||
collection(collections::layer_states(), collections::assert_layer_states);
|
||||
}
|
||||
#[test]
|
||||
fn layouts_and_paperspace() {
|
||||
collection(collections::layouts(), collections::assert_layouts);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Independent synthetic DXF records for the extended geometry slice. Literal group streams
|
||||
//! specify both reader input and writer expectations; neither side obtains its oracle from
|
||||
//! the other. Group meanings: https://ezdxf.readthedocs.io/en/stable/dxfentities/index.html
|
||||
|
||||
use super::super::{cases, compare};
|
||||
use cadcraft_doc::{Arc, Common, Ellipse, EntityKind, LwPolyline, MText, Point, RayLine};
|
||||
use cadcraft_dxf::{Tag, records, sections};
|
||||
use cadcraft_geom::{PolyVertex, Vec2, Vec3};
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
|
||||
struct Fixture {
|
||||
wire_type: &'static str,
|
||||
kind: EntityKind,
|
||||
groups: &'static str,
|
||||
output: Vec<Tag>,
|
||||
}
|
||||
|
||||
fn fixtures() -> Vec<Fixture> {
|
||||
vec![
|
||||
Fixture {
|
||||
wire_type: "ARC",
|
||||
kind: EntityKind::Arc(Arc { center: Vec3::new(1.25, -2.5, 3.75), radius: 2.5, start: 0.0, end: std::f64::consts::FRAC_PI_2 }),
|
||||
groups: "10\n1.25\n20\n-2.5\n30\n3.75\n40\n2.5\n50\n0\n51\n90\n",
|
||||
output: vec![Tag::f(10, 1.25), Tag::f(20, -2.5), Tag::f(30, 3.75), Tag::f(40, 2.5), Tag::f(50, 0.0), Tag::f(51, 90.0)],
|
||||
},
|
||||
Fixture {
|
||||
wire_type: "ELLIPSE",
|
||||
kind: EntityKind::Ellipse(Ellipse {
|
||||
center: Vec3::new(1.0, 2.0, 3.0),
|
||||
major: Vec3::new(3.0, 4.0, 0.0),
|
||||
ratio: 0.5,
|
||||
start: 0.0,
|
||||
end: std::f64::consts::TAU,
|
||||
}),
|
||||
groups: "10\n1\n20\n2\n30\n3\n11\n3\n21\n4\n31\n0\n40\n0.5\n41\n0\n42\n6.283185307179586\n",
|
||||
output: vec![Tag::f(11, 3.0), Tag::f(21, 4.0), Tag::f(31, 0.0), Tag::f(40, 0.5), Tag::f(41, 0.0), Tag::f(42, std::f64::consts::TAU)],
|
||||
},
|
||||
Fixture {
|
||||
wire_type: "LWPOLYLINE",
|
||||
kind: EntityKind::LwPolyline(LwPolyline {
|
||||
vertices: vec![
|
||||
PolyVertex { p: Vec2::new(1.25, -2.5), bulge: 0.5, start_width: 0.125, end_width: 0.75 },
|
||||
PolyVertex::new(Vec2::new(5.0, 3.0)),
|
||||
],
|
||||
closed: true,
|
||||
plinegen: true,
|
||||
const_width: 0.0,
|
||||
elevation: -3.75,
|
||||
}),
|
||||
groups: "90\n2\n70\n129\n38\n-3.75\n10\n1.25\n20\n-2.5\n40\n0.125\n41\n0.75\n42\n0.5\n10\n5\n20\n3\n",
|
||||
output: vec![
|
||||
Tag::i(90, 2),
|
||||
Tag::i(70, 129),
|
||||
Tag::f(38, -3.75),
|
||||
Tag::f(10, 1.25),
|
||||
Tag::f(10, 5.0),
|
||||
Tag::f(20, -2.5),
|
||||
Tag::f(20, 3.0),
|
||||
Tag::f(40, 0.125),
|
||||
Tag::f(41, 0.75),
|
||||
Tag::f(42, 0.5),
|
||||
],
|
||||
},
|
||||
Fixture {
|
||||
wire_type: "MTEXT",
|
||||
kind: EntityKind::MText(MText {
|
||||
insert: Vec3::new(1.25, -2.5, 3.75),
|
||||
height: 2.25,
|
||||
width: 12.5,
|
||||
attach: 5,
|
||||
rotation: std::f64::consts::FRAC_PI_2,
|
||||
style: "Standard".into(),
|
||||
contents: "first\\Psecond".into(),
|
||||
line_spacing: 1.75,
|
||||
line_spacing_exact: true,
|
||||
columns: None,
|
||||
}),
|
||||
groups: "10\n1.25\n20\n-2.5\n30\n3.75\n40\n2.25\n41\n12.5\n71\n5\n50\n90\n7\nStandard\n1\nfirst\\Psecond\n73\n2\n44\n1.75\n",
|
||||
output: vec![
|
||||
Tag::f(40, 2.25),
|
||||
Tag::f(41, 12.5),
|
||||
Tag::i(71, 5),
|
||||
Tag::f(50, 90.0),
|
||||
Tag::s(7, "Standard"),
|
||||
Tag::s(1, "first\\Psecond"),
|
||||
Tag::i(73, 2),
|
||||
Tag::f(44, 1.75),
|
||||
],
|
||||
},
|
||||
Fixture {
|
||||
wire_type: "POINT",
|
||||
kind: EntityKind::Point(Point { p: Vec3::new(1.0, 2.0, 3.0), angle: 0.0 }),
|
||||
groups: "10\n1\n20\n2\n30\n3\n",
|
||||
output: vec![Tag::f(10, 1.0), Tag::f(20, 2.0), Tag::f(30, 3.0)],
|
||||
},
|
||||
Fixture {
|
||||
wire_type: "RAY",
|
||||
kind: EntityKind::Ray(RayLine { base: Vec3::new(1.0, 2.0, 3.0), dir: Vec3::new(0.0, 1.0, 0.0) }),
|
||||
groups: "10\n1\n20\n2\n30\n3\n11\n0\n21\n1\n31\n0\n",
|
||||
output: vec![Tag::f(11, 0.0), Tag::f(21, 1.0), Tag::f(31, 0.0)],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_extended_entities_from_independent_groups() {
|
||||
for fixture in fixtures() {
|
||||
let name = fixture.wire_type;
|
||||
let bytes = format!("0\nSECTION\n2\nENTITIES\n0\n{name}\n5\n100\n{}0\nENDSEC\n0\nEOF\n", fixture.groups);
|
||||
let expected = cases::entity_case(name, Common::default(), fixture.kind.clone());
|
||||
let back = read(bytes.as_bytes(), "fixture.dxf").unwrap();
|
||||
compare::model(&expected.drawing, &back, name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_extended_entities_with_independent_groups() {
|
||||
for fixture in fixtures() {
|
||||
let name = fixture.wire_type;
|
||||
let source = cases::entity_case(name, Common::default(), fixture.kind.clone());
|
||||
for version in Version::ALL {
|
||||
for binary in [false, true] {
|
||||
let bytes = write_with(&source.drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&bytes).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let records = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags);
|
||||
assert_eq!(records.len(), 1);
|
||||
let (kind, groups) = &records[0];
|
||||
assert_eq!(kind, name);
|
||||
for code in fixture.output.iter().map(|t| t.code) {
|
||||
let expected: Vec<_> = fixture.output.iter().filter(|t| t.code == code).collect();
|
||||
let actual: Vec<_> = groups.iter().filter(|t| t.code == code).collect();
|
||||
assert_eq!(actual, expected, "{name} {version} binary={binary} group={code}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! DXF preservation through public Drawing → bytes → Drawing APIs, without disk or UI.
|
||||
//! Covers modeled entity families, stored block graphs, references and selected drawing
|
||||
//! collections, not whole-drawing/session equivalence. Each case runs in ASCII and binary,
|
||||
//! twice, at every representable save version; expectations never come from writer output.
|
||||
//! WIPEOUT's explicit closing marker is an authored format expectation, not a new corner.
|
||||
//!
|
||||
//! Run: `cargo test -p cadcraft-io --test it roundtrip::dxf`.
|
||||
//! Add `-- --ignored` to reproduce the pending preservation cases. Those tests
|
||||
//! assert desired preservation, not today's wrong result; remove `ignore` when fixing them.
|
||||
//! Independent fixtures cover the core entity groups; deep compound-entity interoperability
|
||||
//! still needs separate format checks beyond the self-round-trip matrix.
|
||||
|
||||
mod attributes;
|
||||
mod common;
|
||||
mod conformance;
|
||||
mod entities;
|
||||
mod extended;
|
||||
mod geometry_groups;
|
||||
mod table_flags;
|
||||
|
||||
use super::compare;
|
||||
use cadcraft_doc::Drawing;
|
||||
use cadcraft_io::{SaveOptions, Version, read, save_version, write_with};
|
||||
|
||||
fn check(case: &str, original: &Drawing) {
|
||||
for version in Version::ALL {
|
||||
check_version(case, original, original, version);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_version(case: &str, original: &Drawing, expected: &Drawing, version: Version) {
|
||||
check_version_with(case, original, expected, version, |_, _, _| {});
|
||||
}
|
||||
|
||||
fn check_version_with(case: &str, original: &Drawing, expected: &Drawing, version: Version, extra: impl Fn(&Drawing, &Drawing, &str)) {
|
||||
for binary in [false, true] {
|
||||
let options = SaveOptions { version: Some(version), binary };
|
||||
let mut current = original.clone();
|
||||
for pass in 1..=2 {
|
||||
let context = format!("{case} · {version} · binary={binary} · pass={pass}");
|
||||
let bytes = write_with(¤t, "memory.dxf", &options).unwrap_or_else(|e| panic!("{context}: write: {e}"));
|
||||
assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding");
|
||||
let back = read(&bytes, "memory.dxf").unwrap_or_else(|e| panic!("{context}: read: {e}"));
|
||||
assert_eq!(save_version(&back), version, "{context}: version");
|
||||
assert!(back.read_notes.is_empty(), "{context}: read notes: {:?}", back.read_notes);
|
||||
assert!(back.skipped_records.is_empty(), "{context}: skipped records: {:?}", back.skipped_records);
|
||||
compare::model(expected, &back, &context);
|
||||
extra(expected, &back, &context);
|
||||
if pass == 2 {
|
||||
compare::model(¤t, &back, &format!("{context} · resave stability"));
|
||||
extra(¤t, &back, &format!("{context} · resave stability"));
|
||||
}
|
||||
current = back;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Independent standard LAYER/STYLE flag contracts. Literal wire-to-field mappings cover the
|
||||
//! small modeled flag domains, including off-vs-frozen and vertical-vs-mirrored distinctions.
|
||||
//! Reader fixtures are handwritten records; writer checks inspect tags, not our Drawing reader.
|
||||
//! Annotative STYLE xdata is covered separately by the round-trip tests, not this native-bit test.
|
||||
//! References: Autodesk DXF Reference, LAYER and STYLE symbol table entries.
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{Drawing, Layer, TextStyle};
|
||||
use cadcraft_dxf::{Tag, Value, records, sections};
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
|
||||
fn layers() -> Vec<(Layer, i64)> {
|
||||
let mut cases = Vec::new();
|
||||
// (group 70, frozen, frozen in new viewports, locked). Off is the sign of 62,
|
||||
// not another flag; plot is group 290 and must not accidentally change any of these.
|
||||
for (flags, frozen, vp_freeze_new, locked) in [
|
||||
(0, false, false, false),
|
||||
(1, true, false, false),
|
||||
(2, false, true, false),
|
||||
(3, true, true, false),
|
||||
(4, false, false, true),
|
||||
(5, true, false, true),
|
||||
(6, false, true, true),
|
||||
(7, true, true, true),
|
||||
] {
|
||||
for on in [false, true] {
|
||||
for plot in [false, true] {
|
||||
cases.push((
|
||||
Layer { name: "Flags".into(), color: Color::Index(3), frozen, vp_freeze_new, locked, on, plot, ..Layer::default() },
|
||||
flags,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
cases
|
||||
}
|
||||
|
||||
fn styles() -> Vec<(TextStyle, i64, i64)> {
|
||||
let mut cases = Vec::new();
|
||||
for (generation, backwards, upside_down) in [(0, false, false), (2, true, false), (4, false, true), (6, true, true)] {
|
||||
for (flags, vertical) in [(0, false), (4, true)] {
|
||||
cases.push((
|
||||
TextStyle { name: "Flags".into(), font: "Synthetic.ttf".into(), backwards, upside_down, vertical, ..TextStyle::default() },
|
||||
flags,
|
||||
generation,
|
||||
));
|
||||
}
|
||||
}
|
||||
cases
|
||||
}
|
||||
|
||||
fn fixture(table: &str, body: &str) -> Drawing {
|
||||
let bytes = format!("0\nSECTION\n2\nTABLES\n0\nTABLE\n2\n{table}\n70\n1\n0\n{table}\n2\nFlags\n{body}0\nENDTAB\n0\nENDSEC\n0\nEOF\n");
|
||||
let drawing = read(bytes.as_bytes(), "fixture.dxf").unwrap();
|
||||
assert!(drawing.read_notes.is_empty(), "{:?}", drawing.read_notes);
|
||||
assert!(drawing.skipped_records.is_empty());
|
||||
drawing
|
||||
}
|
||||
|
||||
fn output(drawing: &Drawing, table: &str, version: Version, binary: bool) -> Vec<Tag> {
|
||||
let bytes = write_with(drawing, "memory.dxf", &SaveOptions { version: Some(version), binary }).unwrap();
|
||||
let tags = cadcraft_dxf::parse(&bytes).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let tables = records(§ions.iter().find(|s| s.name == "TABLES").unwrap().tags);
|
||||
let matching: Vec<_> = tables.into_iter().filter(|(kind, groups)| kind == table && groups.contains(&Tag::s(2, "Flags"))).collect();
|
||||
assert_eq!(matching.len(), 1, "{version} binary={binary} {table}");
|
||||
matching.into_iter().next().unwrap().1
|
||||
}
|
||||
|
||||
fn group(tags: &[Tag], expected: Tag, context: &str) {
|
||||
assert_eq!(tags.iter().filter(|t| t.code == expected.code).collect::<Vec<_>>(), [&expected], "{context}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_all_layer_flag_combinations() {
|
||||
for (expected, flags) in layers() {
|
||||
let aci = if expected.on { 3 } else { -3 };
|
||||
let plot = i64::from(expected.plot);
|
||||
let drawing = fixture("LAYER", &format!("70\n{flags}\n62\n{aci}\n6\nContinuous\n290\n{plot}\n"));
|
||||
assert_eq!(drawing.layer("Flags"), Some(&expected), "70={flags} 62={aci} 290={plot}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_all_layer_flag_combinations() {
|
||||
for (layer, flags) in layers() {
|
||||
let mut drawing = Drawing::new_metric();
|
||||
drawing.layers.push(layer.clone());
|
||||
for version in Version::ALL {
|
||||
for binary in [false, true] {
|
||||
let tags = output(&drawing, "LAYER", version, binary);
|
||||
let context = format!("{version} binary={binary} {layer:?}");
|
||||
group(&tags, Tag::i(70, flags), &context);
|
||||
group(&tags, Tag::i(62, if layer.on { 3 } else { -3 }), &context);
|
||||
if layer.plot {
|
||||
// This writer omits the default true; the fixture above tests explicit true.
|
||||
assert!(!tags.iter().any(|t| t.code == 290), "{context}");
|
||||
} else {
|
||||
group(&tags, Tag::new(290, Value::Bool(false)), &context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_all_native_text_style_flags() {
|
||||
for (expected, flags, generation) in styles() {
|
||||
let drawing = fixture("STYLE", &format!("70\n{flags}\n71\n{generation}\n40\n0\n41\n1\n50\n0\n3\nSynthetic.ttf\n4\n\n"));
|
||||
assert_eq!(drawing.text_style("Flags"), Some(&expected), "70={flags} 71={generation}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_all_native_text_style_flags() {
|
||||
for (style, flags, generation) in styles() {
|
||||
let mut drawing = Drawing::new_metric();
|
||||
drawing.text_styles.push(style.clone());
|
||||
for version in Version::ALL {
|
||||
for binary in [false, true] {
|
||||
let tags = output(&drawing, "STYLE", version, binary);
|
||||
let context = format!("{version} binary={binary} {style:?}");
|
||||
group(&tags, Tag::i(70, flags), &context);
|
||||
group(&tags, Tag::i(71, generation), &context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Exhausting generated-handle space may be refused, but must not silently lose authored
|
||||
//! entities. This is separate from the preservation matrix's large handles with free capacity.
|
||||
//! Neither a particular allocator strategy nor a successful save is required at exhaustion.
|
||||
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
|
||||
use super::{cases::properties, compare};
|
||||
|
||||
fn preserves_or_refuses(path: &str, binary: bool) {
|
||||
let original = properties::exhausted_handles().drawing;
|
||||
for version in Version::ALL {
|
||||
let options = SaveOptions { version: Some(version), binary };
|
||||
let mut current = original.clone();
|
||||
for pass in 1..=2 {
|
||||
let context = format!("exhausted handles · {path} {version} binary={binary} pass={pass}");
|
||||
let Ok(bytes) = write_with(¤t, path, &options) else {
|
||||
break; // An explicit save error is safe; a nominal success must preserve the data.
|
||||
};
|
||||
let back = read(&bytes, path).unwrap_or_else(|e| panic!("{context}: successful save is unreadable: {e}"));
|
||||
assert!(back.read_notes.is_empty(), "{context}: {:?}", back.read_notes);
|
||||
assert!(back.skipped_records.is_empty(), "{context}: {:?}", back.skipped_records);
|
||||
compare::model(&original, &back, &context);
|
||||
current = back;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Confirmed DXF data loss: exhausted generated handles produce a successful save that reopens with no model entities"]
|
||||
fn dxf_exhausted_handles_are_preserved_or_refused() {
|
||||
for binary in [false, true] {
|
||||
preserves_or_refuses("memory.dxf", binary);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn dwg_exhausted_handles_are_preserved_or_refused() {
|
||||
preserves_or_refuses("memory.dwg", false);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! In-memory preservation tests grouped by file format. DXF and DWG use the same drawing cases
|
||||
//! and strict assertions; each format owns its version domain and explicitly pending regressions.
|
||||
|
||||
mod cases;
|
||||
mod collections;
|
||||
mod compare;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod dwg;
|
||||
mod dxf;
|
||||
mod handle_limits;
|
||||
mod unknown;
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Unknown-entity preservation is payload- and reference-aware: the entity's handle, Common
|
||||
//! fields, model-space membership and every application payload tag must survive. Transport
|
||||
//! owner handles may be reallocated, but the DXF owner must still resolve to model space.
|
||||
//! DWG may explicitly refuse an unregistered class; a successful save must not silently drop it.
|
||||
|
||||
use cadcraft_doc::{Common, Drawing, Entity, EntityKind, Line, RawTag, Unknown};
|
||||
use cadcraft_dxf::{records, sections};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_io::{SaveOptions, Version, read, write_with};
|
||||
|
||||
fn drawing() -> Drawing {
|
||||
let mut d = super::cases::drawing();
|
||||
let handle = d.new_handle();
|
||||
let target = d.new_handle();
|
||||
let tags = vec![
|
||||
RawTag { code: 5, value: handle.hex() },
|
||||
// The store supplies the owner when saved, as it does for every modeled entity.
|
||||
RawTag { code: 330, value: "0".into() },
|
||||
RawTag { code: 100, value: "AcDbEntity".into() },
|
||||
RawTag { code: 8, value: "0".into() },
|
||||
RawTag { code: 100, value: "SyntheticWidget".into() },
|
||||
RawTag { code: 1, value: "Original Ø payload".into() },
|
||||
RawTag { code: 90, value: "-7".into() },
|
||||
RawTag { code: 310, value: "00FF80".into() },
|
||||
RawTag { code: 10, value: "1.25".into() },
|
||||
RawTag { code: 20, value: "-2.5".into() },
|
||||
RawTag { code: 30, value: "3.75".into() },
|
||||
RawTag { code: 330, value: target.hex() },
|
||||
];
|
||||
d.model.push(Entity::new(handle, EntityKind::Unknown(Unknown { dxf_type: "SYNTHETIC_WIDGET".into(), tags })));
|
||||
d.model.push(Entity::new(target, EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(5.0, 6.0, 7.0) })));
|
||||
d
|
||||
}
|
||||
|
||||
fn payload(tags: &[RawTag]) -> &[RawTag] {
|
||||
let start = tags.iter().position(|t| t.code == 100 && t.value == "SyntheticWidget").expect("application subclass must survive");
|
||||
&tags[start..]
|
||||
}
|
||||
|
||||
fn preserved(original: &Drawing, back: &Drawing, context: &str) {
|
||||
assert!(back.read_notes.is_empty(), "{context}: {:?}", back.read_notes);
|
||||
assert!(back.skipped_records.is_empty(), "{context}: {:?}", back.skipped_records);
|
||||
assert_eq!(back.model.len(), original.model.len(), "{context}: entity count");
|
||||
for (a, b) in original.model.iter().zip(back.model.iter()) {
|
||||
assert_eq!(b.handle, a.handle, "{context}: handle");
|
||||
assert_eq!(b.common, a.common, "{context}: Common");
|
||||
match (&a.kind, &b.kind) {
|
||||
(EntityKind::Unknown(a), EntityKind::Unknown(b)) => {
|
||||
assert_eq!(b.dxf_type, a.dxf_type, "{context}: application entity type");
|
||||
assert_eq!(payload(&b.tags), payload(&a.tags), "{context}: full payload including handle reference");
|
||||
}
|
||||
_ => assert_eq!(b.kind, a.kind, "{context}: geometry"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dxf_owner(bytes: &[u8]) {
|
||||
let tags = cadcraft_dxf::parse(bytes).unwrap();
|
||||
let sections = sections(&tags);
|
||||
let tables = records(§ions.iter().find(|s| s.name == "TABLES").unwrap().tags);
|
||||
let (_, model) = tables
|
||||
.iter()
|
||||
.find(|(kind, tags)| kind == "BLOCK_RECORD" && tags.iter().any(|t| t.code == 2 && t.str().eq_ignore_ascii_case("*Model_Space")))
|
||||
.unwrap();
|
||||
let model_handle = model.iter().find(|t| t.code == 5).unwrap().str();
|
||||
let entities = records(§ions.iter().find(|s| s.name == "ENTITIES").unwrap().tags);
|
||||
let (_, widget) = entities.iter().find(|(kind, _)| kind == "SYNTHETIC_WIDGET").unwrap();
|
||||
assert_eq!(widget.iter().find(|t| t.code == 330).unwrap().str(), model_handle, "unknown entity belongs to the model-space block");
|
||||
}
|
||||
|
||||
pub(super) fn check(path: &str, version: Version, binary: bool) {
|
||||
let original = drawing();
|
||||
let mut current = original.clone();
|
||||
for pass in 1..=2 {
|
||||
let context = format!("unknown entity {path} {version} binary={binary} pass={pass}");
|
||||
let bytes = match write_with(¤t, path, &SaveOptions { version: Some(version), binary }) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(_) if path.ends_with(".dwg") && pass == 1 => return,
|
||||
Err(error) => panic!("{context}: {error}"),
|
||||
};
|
||||
if path.ends_with(".dxf") {
|
||||
assert_eq!(cadcraft_dxf::is_binary(&bytes), binary, "{context}: encoding");
|
||||
dxf_owner(&bytes);
|
||||
} else {
|
||||
assert_eq!(cadcraft_dwg::version(&bytes).as_deref(), Some(version.code()), "{context}: wire version");
|
||||
}
|
||||
let back = read(&bytes, path).unwrap();
|
||||
preserved(&original, &back, &context);
|
||||
preserved(¤t, &back, &context);
|
||||
current = back;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_payload_assertion_rejects_lost_references() {
|
||||
let original = drawing();
|
||||
preserved(&original, &original, "unchanged unknown entity");
|
||||
let mut changed = original.clone();
|
||||
let handle = changed.model.iter().next().unwrap().handle;
|
||||
changed.model.modify(handle, |e| {
|
||||
let EntityKind::Unknown(u) = &mut e.kind else { panic!("expected unknown entity") };
|
||||
u.tags.pop();
|
||||
});
|
||||
assert_eq!(original.model.iter().next().unwrap().common, Common::default());
|
||||
assert!(std::panic::catch_unwind(|| preserved(&original, &changed, "missing application reference")).is_err());
|
||||
}
|
||||
@@ -75,7 +75,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG
|
||||
|---|---|---|---|
|
||||
| LINE, POINT, CIRCLE, ARC, ELLIPSE, LWPOLYLINE (widths, bulges, elevation), SPLINE, RAY, XLINE | yes | yes | round-trip tests |
|
||||
| POLYLINE 2D/3D | yes | yes (as LWPOLYLINE / 3D POLYLINE) | polyface and polygon meshes flattened: lossy |
|
||||
| TEXT, MTEXT, ATTDEF, INSERT + ATTRIB | yes; multiline attributes (embedded MTEXT) read and drawn | yes; multiline attributes written as embedded MTEXT (DXF 2007 and later; older versions: one line); changed attribute values refresh justified baselines, including small width changes | ATTDEF prompt kept (#137); unchanged values retain imported-font placement |
|
||||
| TEXT, MTEXT, ATTDEF, INSERT + ATTRIB | yes; multiline attributes (embedded MTEXT) read and drawn | yes; multiline attributes written as embedded MTEXT (DXF 2007 and later; older versions: one line); changed attribute values refresh justified baselines, including small width changes | ATTDEF prompt kept (#137); unchanged values retain imported-font placement; a justified TEXT's insertion point (group 10, which AutoCAD takes from a DWG as it is) is computed as AutoCAD computes it: Middle (M) centred on the string's own extents, bottom justifications at a TrueType face's descent (#2229) |
|
||||
| DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written for every associated kind — linear/aligned, angular, radial/diametric, ordinate and arc length — and read back without CADCraft's xdata (#1651) |
|
||||
| DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written; angular block text honors DIMDSEP, including per-dimension overrides (#1646) |
|
||||
| DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks; their text keeps the diameter sign as `%%c`, so SHX styles plot it (#1645) | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written |
|
||||
|
||||
Reference in New Issue
Block a user