diff --git a/crates/dxf/tests/conformance.rs b/crates/dxf/tests/conformance.rs new file mode 100644 index 000000000..089ed8063 --- /dev/null +++ b/crates/dxf/tests/conformance.rs @@ -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")]); + } + } +} diff --git a/crates/dxf/tests/hex_chunks.rs b/crates/dxf/tests/hex_chunks.rs new file mode 100644 index 000000000..e3fdc6948 --- /dev/null +++ b/crates/dxf/tests/hex_chunks.rs @@ -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 { + (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::().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); + } + } + } +} diff --git a/crates/dxf/tests/roundtrip.rs b/crates/dxf/tests/roundtrip.rs new file mode 100644 index 000000000..acdcb7a78 --- /dev/null +++ b/crates/dxf/tests/roundtrip.rs @@ -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, ""), + ], + ); +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 13af05646..5bbae6883 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -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 { + 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}"); + } +} diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index 6b4c5ebdd..9c1888aa8 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -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 { + 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 { diff --git a/crates/io/tests/it/main.rs b/crates/io/tests/it/main.rs index 5d6c343d0..7f6ad8f20 100644 --- a/crates/io/tests/it/main.rs +++ b/crates/io/tests/it/main.rs @@ -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; diff --git a/crates/io/tests/it/roundtrip/cases.rs b/crates/io/tests/it/roundtrip/cases.rs new file mode 100644 index 000000000..a6031ec8f --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases.rs @@ -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 { + vec![Case { name: "empty-model".into(), drawing: drawing() }, common_case("common/default", Common::default())] +} + +pub(super) fn geometry() -> Vec { + 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 { + ["0", "Survey_Ä"].map(|layer| common_case(&format!("common/layer={layer}"), Common { layer: layer.into(), ..Common::default() })).into() +} + +pub(super) fn layer_linetypes() -> Vec { + ["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 { + [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 { + ["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 { + [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 { + [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 { + [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 { + [false, true].map(|visible| common_case(&format!("common/visible={visible}"), Common { visible, ..Common::default() })).into() +} + +pub(super) fn thicknesses() -> Vec { + [-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 { + ["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 { + vec![common_case("common/plot_style=ByBlock", Common { plot_style: "ByBlock".into(), ..Common::default() })] +} + +pub(super) fn extrusions() -> Vec { + [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 { + 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 { + // 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 { + [("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 { + 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 { + [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 { + [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 { + [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 { + [-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 { + ["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 { + [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 { + // 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 { + 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 { + 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 +} diff --git a/crates/io/tests/it/roundtrip/cases/blocks.rs b/crates/io/tests/it/roundtrip/cases/blocks.rs new file mode 100644 index 000000000..a1af6218a --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases/blocks.rs @@ -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 { + 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 { + [(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 { + 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 { + 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 { + 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 { + [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 { + // 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 { + 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] +} diff --git a/crates/io/tests/it/roundtrip/cases/complex.rs b/crates/io/tests/it/roundtrip/cases/complex.rs new file mode 100644 index 000000000..ab13f4800 --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases/complex.rs @@ -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 { + [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 { + [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 { + [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 { + 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 { + 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 { + [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() +} diff --git a/crates/io/tests/it/roundtrip/cases/geometry.rs b/crates/io/tests/it/roundtrip/cases/geometry.rs new file mode 100644 index 000000000..35a2cc9dc --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases/geometry.rs @@ -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 { + 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 { + [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 { + 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 { + [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 { + [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 { + 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 { + 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 { + 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 { + (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 { + [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 { + ["", " 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 { + 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 { + [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 { + [ + 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 { + [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 { + 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 { + 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 { + 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 { + [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() +} diff --git a/crates/io/tests/it/roundtrip/cases/properties.rs b/crates/io/tests/it/roundtrip/cases/properties.rs new file mode 100644 index 000000000..eab95e8ac --- /dev/null +++ b/crates/io/tests/it/roundtrip/cases/properties.rs @@ -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 { + 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 { + (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 { + [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 { + ["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 { + [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 { + (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 { + // 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 { + [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 { + 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 { + (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 { + 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 { + sparse_handles(&[0x1_0000, 0x1_0000_0000]) +} + +pub(crate) fn handles_near_integer_limits() -> Vec { + // 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 { + 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 { + [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 { + 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 { + (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 { + 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 { + [254, 255, 256, 1024] + .map(|length| { + entity_case(&format!("text/length={length}"), Common::default(), EntityKind::Text(Text { value: "Ø".repeat(length), ..text() })) + }) + .into() +} diff --git a/crates/io/tests/it/roundtrip/collections.rs b/crates/io/tests/it/roundtrip/collections.rs new file mode 100644 index 000000000..9b9acf36b --- /dev/null +++ b/crates/io/tests/it/roundtrip/collections.rs @@ -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)); + } +} diff --git a/crates/io/tests/it/roundtrip/compare.rs b/crates/io/tests/it/roundtrip/compare.rs new file mode 100644 index 000000000..d4bd1541a --- /dev/null +++ b/crates/io/tests/it/roundtrip/compare.rs @@ -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(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()); + } +} diff --git a/crates/io/tests/it/roundtrip/dwg/conformance.rs b/crates/io/tests/it/roundtrip/dwg/conformance.rs new file mode 100644 index 000000000..ff58850ce --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/conformance.rs @@ -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:?}"); + } + } +} diff --git a/crates/io/tests/it/roundtrip/dwg/dimensions.rs b/crates/io/tests/it/roundtrip/dwg/dimensions.rs new file mode 100644 index 000000000..3599d43f5 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/dimensions.rs @@ -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 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::>().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 { + 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()); +} diff --git a/crates/io/tests/it/roundtrip/dwg/entities.rs b/crates/io/tests/it/roundtrip/dwg/entities.rs new file mode 100644 index 000000000..58f97d92f --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/entities.rs @@ -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); + } +} diff --git a/crates/io/tests/it/roundtrip/dwg/extended.rs b/crates/io/tests/it/roundtrip/dwg/extended.rs new file mode 100644 index 000000000..001712d28 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/extended.rs @@ -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) { + 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) { + // 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); + } + } +} diff --git a/crates/io/tests/it/roundtrip/dwg/mod.rs b/crates/io/tests/it/roundtrip/dwg/mod.rs new file mode 100644 index 000000000..6f9c90e1d --- /dev/null +++ b/crates/io/tests/it/roundtrip/dwg/mod.rs @@ -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) { + 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; + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/attributes.rs b/crates/io/tests/it/roundtrip/dxf/attributes.rs new file mode 100644 index 000000000..0962f7bc9 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/attributes.rs @@ -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); + } + } + } + } + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/common.rs b/crates/io/tests/it/roundtrip/dxf/common.rs new file mode 100644 index 000000000..6e40977c9 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/common.rs @@ -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)> { + 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::>(), ["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::>(), [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"); + } + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/conformance.rs b/crates/io/tests/it/roundtrip/dxf/conformance.rs new file mode 100644 index 000000000..3f036be2c --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/conformance.rs @@ -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::>(), 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::>(), [&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)); +} diff --git a/crates/io/tests/it/roundtrip/dxf/entities.rs b/crates/io/tests/it/roundtrip/dxf/entities.rs new file mode 100644 index 000000000..5816d5017 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/entities.rs @@ -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) { + 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()); +} diff --git a/crates/io/tests/it/roundtrip/dxf/extended.rs b/crates/io/tests/it/roundtrip/dxf/extended.rs new file mode 100644 index 000000000..4e8415337 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/extended.rs @@ -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) { + assert!(!cases.is_empty()); + for case in cases { + super::check(&case.name, &case.drawing); + } +} +fn since_2007(cases: Vec) { + 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); +} diff --git a/crates/io/tests/it/roundtrip/dxf/geometry_groups.rs b/crates/io/tests/it/roundtrip/dxf/geometry_groups.rs new file mode 100644 index 000000000..b447638eb --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/geometry_groups.rs @@ -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, +} + +fn fixtures() -> Vec { + 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}"); + } + } + } + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/mod.rs b/crates/io/tests/it/roundtrip/dxf/mod.rs new file mode 100644 index 000000000..929c1a483 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/mod.rs @@ -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; + } + } +} diff --git a/crates/io/tests/it/roundtrip/dxf/table_flags.rs b/crates/io/tests/it/roundtrip/dxf/table_flags.rs new file mode 100644 index 000000000..cefc28bb1 --- /dev/null +++ b/crates/io/tests/it/roundtrip/dxf/table_flags.rs @@ -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 { + 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::>(), [&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); + } + } + } +} diff --git a/crates/io/tests/it/roundtrip/handle_limits.rs b/crates/io/tests/it/roundtrip/handle_limits.rs new file mode 100644 index 000000000..3cff2b3e4 --- /dev/null +++ b/crates/io/tests/it/roundtrip/handle_limits.rs @@ -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); +} diff --git a/crates/io/tests/it/roundtrip/mod.rs b/crates/io/tests/it/roundtrip/mod.rs new file mode 100644 index 000000000..d38f84eb1 --- /dev/null +++ b/crates/io/tests/it/roundtrip/mod.rs @@ -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; diff --git a/crates/io/tests/it/roundtrip/unknown.rs b/crates/io/tests/it/roundtrip/unknown.rs new file mode 100644 index 000000000..5883a078c --- /dev/null +++ b/crates/io/tests/it/roundtrip/unknown.rs @@ -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()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 880364d6f..d85415123 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 |