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