From 064f58f252c9a550d9fce02f43f4e8ad7b0f372f Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Tue, 3 Nov 2026 21:16:55 -0700 Subject: [PATCH] XCLIP: write an inverted clip as AutoCAD does in a file (no group 290) AutoCAD 2024 writes no group 290 on a SPATIAL_FILTER in a DXF and discards a file whose filter has one (measured by 2ez4gcx, #2756). An inverted clip's filter now gets an extension dictionary with the ACAD_XREC_ROUNDTRIP XRECORD (ACAD_INVERTEDCLIP_ROUNDTRIP, then ..._COMPARE). Reading takes the ROUNDTRIP list as the boundary while COMPARE matches the filter's vertices, so AutoCAD's keyhole filters read as the clip drawn; 290 is still read when present. The record survives DWG, so CADCraft's own CADCRAFT_XCLIP flag is gone. --- crates/io/src/dxf_clip.rs | 288 +++++++++++++++++--------- crates/io/tests/it/xclip_roundtrip.rs | 15 +- 2 files changed, 203 insertions(+), 100 deletions(-) diff --git a/crates/io/src/dxf_clip.rs b/crates/io/src/dxf_clip.rs index 0e120d9be..fcb615a9c 100644 --- a/crates/io/src/dxf_clip.rs +++ b/crates/io/src/dxf_clip.rs @@ -4,11 +4,15 @@ //! 10/20 the vertices), whether clipping is on (71), the front and back clip (72/73, 40/41) and //! two 3×4 matrices (group 40, row by row): the inverse of the reference's transform, which takes //! the boundary into the block's coordinates, and the boundary's own transform (identity here). -//! Group 290 is 1 for an inverted clip ("Invert clip"), as the reference app writes it. //! -//! The DWG bridge drops group 290, so an inverted clip is also marked by CADCraft's own entry -//! `CADCRAFT_XCLIP` beside `ACAD_FILTER` (an XRECORD with group 70 = 1), which a DWG keeps. It -//! is read only when the filter has no group 290. +//! An inverted clip ("Invert clip") is marked as the reference app writes it in a file: the +//! filter has an extension dictionary whose `ACAD_XREC_ROUNDTRIP` entry is an XRECORD holding +//! `102 ACAD_INVERTEDCLIP_ROUNDTRIP` then the boundary (10/20/30), and +//! `102 ACAD_INVERTEDCLIP_ROUNDTRIP_COMPARE` then the filter's own vertices. While the filter +//! still holds those vertices the clip is inverted, with the first list as its boundary. The +//! reference app shows the flag as group 290 on the object but never writes 290 in a DXF file, +//! and discards a file whose filter has one; 290 is read when another program wrote it, never +//! written. The record goes through DWG as it is. //! //! Reading takes the planar filters the model can hold out of the kept foreign data and puts them //! on the references ([`read`]); others (front/back clipping, a skewed boundary transform) stay @@ -23,8 +27,10 @@ use cadcraft_doc::foreign::{Foreign, ForeignObject}; use cadcraft_doc::{ClipBoundary, Drawing, EntityKind, Handle, MAX_CLIP_VERTICES, RawTag}; use cadcraft_geom::{Mat3, Vec2}; -/// The extension-dictionary entry of CADCraft's inverted-clip flag (for DWG, see above). -const INVERT_KEY: &str = "CADCRAFT_XCLIP"; +/// The filter's extension-dictionary entry of the reference app's inverted-clip record. +const ROUNDTRIP_KEY: &str = "ACAD_XREC_ROUNDTRIP"; +const ROUNDTRIP: &str = "ACAD_INVERTEDCLIP_ROUNDTRIP"; +const COMPARE: &str = "ACAD_INVERTEDCLIP_ROUNDTRIP_COMPARE"; /// Handles above this are not handed out (the DXF handle space is 64-bit, keep a margin). const MAX_HANDLE: u64 = 1 << 48; @@ -94,9 +100,70 @@ fn matrix_values(m: &Mat3) -> [f64; 12] { [m.a, m.c, 0.0, m.e, m.b, m.d, 0.0, m.f, 0.0, 0.0, 1.0, 0.0] } -/// The boundary a planar SPATIAL_FILTER holds, in the block's coordinates. `None` for anything +/// The `count` vertices (10/20) a SPATIAL_FILTER holds, as written. +fn vertices(tags: &[RawTag], count: usize) -> Option> { + let mut points = Vec::with_capacity(count); + for w in tags.windows(2) { + let [x, y] = w else { continue }; + if x.code != 10 { + continue; + } + if y.code != 20 || points.len() >= count { + return None; + } + points.push(Vec2::new(x.value.trim().parse().ok()?, y.value.trim().parse().ok()?)); + } + (points.len() == count).then_some(points) +} + +/// The handle of an object's own extension dictionary (`102 {ACAD_XDICTIONARY`, 360). +fn own_xdict(tags: &[RawTag]) -> Option { + tags.windows(2) + .find(|w| matches!(w, [k, v] if k.code == 102 && k.value.trim().eq_ignore_ascii_case("{ACAD_XDICTIONARY") && v.code == 360)) + .and_then(|w| w.get(1)) + .and_then(|v| Handle::parse_hex(&v.value)) +} + +/// The boundary of an inverted clip as the reference app marks it on `filter` (see above), when +/// the filter still holds the vertices the mark was made for. +fn roundtrip(f: &Foreign, idx: &Index, filter: &ForeignObject) -> Option> { + let rec = object(f, idx, entry(&object(f, idx, own_xdict(&filter.tags)?)?.tags, ROUNDTRIP_KEY)?)?; + if !rec.kind.eq_ignore_ascii_case("XRECORD") { + return None; + } + let (mut boundary, mut compare) = (Vec::new(), Vec::new()); + // 0 before either list, 1 in the boundary, 2 in the filter's vertices. + let mut list = 0; + for w in rec.tags.windows(2) { + let [x, y] = w else { continue }; + match x.code { + 102 => { + list = match x.value.trim() { + v if v.eq_ignore_ascii_case(ROUNDTRIP) => 1, + v if v.eq_ignore_ascii_case(COMPARE) => 2, + _ => 0, + } + } + 10 if list != 0 && y.code == 20 => { + let into = if list == 1 { &mut boundary } else { &mut compare }; + if into.len() >= MAX_CLIP_VERTICES { + return None; + } + into.push(Vec2::new(x.value.trim().parse().ok()?, y.value.trim().parse().ok()?)); + } + _ => {} + } + } + let own = vertices(&filter.tags, usize::try_from(number(&filter.tags, 70)? as i64).ok()?)?; + let near = |a: f64, b: f64| (a - b).abs() <= 1e-9 * (1.0 + a.abs().max(b.abs())); + let same = own.len() == compare.len() && own.iter().zip(&compare).all(|(p, q)| near(p.x, q.x) && near(p.y, q.y)); + (same && boundary.len() >= 2).then_some(boundary) +} + +/// The boundary a planar SPATIAL_FILTER holds, in the block's coordinates; with `boundary`, the +/// clip is inverted and that is its outline (in the filter's coordinates). `None` for anything /// the model can't hold as it is (it then stays foreign). -fn parse_filter(o: &ForeignObject) -> Option { +fn parse_filter(o: &ForeignObject, boundary: Option<&[Vec2]>) -> Option { let count = usize::try_from(number(&o.tags, 70)? as i64).ok()?; if !(2..=MAX_CLIP_VERTICES).contains(&count) { return None; @@ -124,31 +191,18 @@ fn parse_filter(o: &ForeignObject) -> Option { { return None; } - let mut points = Vec::with_capacity(count); - for w in o.tags.windows(2) { - let [x, y] = w else { continue }; - if x.code != 10 { - continue; - } - if y.code != 20 || points.len() >= count { - return None; - } - let p = inverse.apply(Vec2::new(x.value.trim().parse().ok()?, y.value.trim().parse().ok()?)); - if !p.is_finite() { - return None; - } - points.push(p); + let own = vertices(&o.tags, count)?; + let outline = boundary.unwrap_or(&own); + if !(2..=MAX_CLIP_VERTICES).contains(&outline.len()) { + return None; } - let clip = ClipBoundary { points, enabled: number(&o.tags, 71).unwrap_or(1.0) != 0.0, inverted: number(&o.tags, 290).is_some_and(|v| v != 0.0) }; - (clip.points.len() == count && cadcraft_render::clip::PolyClip::new(&clip.polygon()).is_some()).then_some(clip) -} - -/// Whether the extension dictionary `xd` marks the clip inverted (CADCraft's own flag). -fn inverted_record(f: &Foreign, idx: &Index, xd: Handle) -> bool { - object(f, idx, xd) - .and_then(|o| entry(&o.tags, INVERT_KEY)) - .and_then(|h| object(f, idx, h)) - .is_some_and(|o| o.kind.eq_ignore_ascii_case("XRECORD") && number(&o.tags, 70).is_some_and(|v| v != 0.0)) + let points: Vec = outline.iter().map(|p| inverse.apply(*p)).collect(); + if !points.iter().all(|p| p.is_finite()) { + return None; + } + let inverted = boundary.is_some() || number(&o.tags, 290).is_some_and(|v| v != 0.0); + let clip = ClipBoundary { points, enabled: number(&o.tags, 71).unwrap_or(1.0) != 0.0, inverted }; + cadcraft_render::clip::PolyClip::new(&clip.polygon()).is_some().then_some(clip) } /// Put the planar filters of the file read into `d` on their references, taking them out of @@ -163,12 +217,9 @@ pub(crate) fn read(d: &mut Drawing) { .entities .keys() .filter_map(|h| { - let (xd, _, o) = chain(&d.foreign, &idx, *h)?; - let mut clip = parse_filter(o)?; - if number(&o.tags, 290).is_none() { - clip.inverted = inverted_record(&d.foreign, &idx, xd); - } - Some((*h, clip)) + let (_, _, o) = chain(&d.foreign, &idx, *h)?; + let boundary = roundtrip(&d.foreign, &idx, o); + Some((*h, parse_filter(o, boundary.as_deref())?)) }) .collect(); if found.is_empty() { @@ -207,7 +258,7 @@ pub(crate) fn read(d: &mut Drawing) { } /// Take the clip of entity `h` out of the foreign data: the ACAD_FILTER entry and what it holds, -/// CADCraft's inverted flag, and the extension dictionary when nothing else is left in it. The +/// and the extension dictionary when nothing else is left in it. The /// objects to remove are added to `gone`; the caller removes them all at once. fn forget(f: &mut Foreign, idx: &Index, h: Handle, all_gone: &mut HashSet) { let Some(xd) = f.entities.get(&h).and_then(|x| x.xdict) else { return }; @@ -215,19 +266,19 @@ fn forget(f: &mut Foreign, idx: &Index, h: Handle, all_gone: &mut HashSet Handle::parse_hex(&v.value), - _ => None, - })); + if let Some(o) = object(f, idx, g) { + if o.kind.eq_ignore_ascii_case("DICTIONARY") { + gone.extend(o.tags.windows(2).filter_map(|w| match w { + [k, v] if k.code == 3 && matches!(v.code, 350 | 360) => Handle::parse_hex(&v.value), + _ => None, + })); + } + gone.extend(own_xdict(&o.tags)); } i += 1; if i > MAX_CLIP_VERTICES { @@ -263,17 +314,14 @@ fn dictionary(h: Handle, owner: Handle) -> ForeignObject { } } -fn spatial_filter(h: Handle, owner: Handle, clip: &ClipBoundary, to_world: &Mat3, inverse: &Mat3) -> ForeignObject { - let mut tags = vec![ - raw(5, h.hex()), - raw(102, "{ACAD_REACTORS"), - raw(330, owner.hex()), - raw(102, "}"), - raw(330, owner.hex()), - raw(100, "AcDbFilter"), - raw(100, "AcDbSpatialFilter"), - raw(70, clip.points.len()), - ]; +/// The SPATIAL_FILTER of `clip`; `xdict` is its extension dictionary (an inverted clip's). +/// No group 290: the reference app discards a file whose filter has one. +fn spatial_filter(h: Handle, owner: Handle, clip: &ClipBoundary, to_world: &Mat3, inverse: &Mat3, xdict: Option) -> ForeignObject { + let mut tags = vec![raw(5, h.hex()), raw(102, "{ACAD_REACTORS"), raw(330, owner.hex()), raw(102, "}")]; + if let Some(x) = xdict { + tags.extend([raw(102, "{ACAD_XDICTIONARY"), raw(360, x.hex()), raw(102, "}")]); + } + tags.extend([raw(330, owner.hex()), raw(100, "AcDbFilter"), raw(100, "AcDbSpatialFilter"), raw(70, clip.points.len())]); for p in &clip.points { let q = to_world.apply(*p); tags.push(raw(10, q.x)); @@ -284,25 +332,21 @@ fn spatial_filter(h: Handle, owner: Handle, clip: &ClipBoundary, to_world: &Mat3 for v in matrix_values(inverse).into_iter().chain(matrix_values(&Mat3::IDENTITY)) { tags.push(raw(40, v)); } - tags.push(raw(290, i32::from(clip.inverted))); ForeignObject { handle: h, kind: "SPATIAL_FILTER".into(), tags } } -fn invert_record(h: Handle, owner: Handle) -> ForeignObject { - ForeignObject { - handle: h, - kind: "XRECORD".into(), - tags: vec![ - raw(5, h.hex()), - raw(102, "{ACAD_REACTORS"), - raw(330, owner.hex()), - raw(102, "}"), - raw(330, owner.hex()), - raw(100, "AcDbXrecord"), - raw(280, 1), - raw(70, 1), - ], +/// The reference app's inverted-clip record for a filter whose vertices are `points` (drawing +/// coordinates): the boundary and the filter's vertices are the same list. +fn roundtrip_record(h: Handle, owner: Handle, points: &[Vec2]) -> ForeignObject { + let mut tags = vec![raw(5, h.hex()), raw(102, "{ACAD_REACTORS"), raw(330, owner.hex()), raw(102, "}")]; + tags.extend([raw(330, owner.hex()), raw(100, "AcDbXrecord"), raw(280, 1)]); + for name in [ROUNDTRIP, COMPARE] { + tags.push(raw(102, name)); + for p in points { + tags.extend([raw(10, p.x), raw(20, p.y), raw(30, 0.0)]); + } } + ForeignObject { handle: h, kind: "XRECORD".into(), tags } } /// A copy of `d` whose foreign data holds the SPATIAL_FILTER of every clipped reference (any @@ -336,7 +380,7 @@ pub(crate) fn prepare(d: &Drawing) -> Option { let mut gone = HashSet::new(); for (insert, clip, to_world) in clips { let Some(inverse) = to_world.inverse() else { continue }; - if next.saturating_add(5) >= MAX_HANDLE { + if next.saturating_add(6) >= MAX_HANDLE { break; } // A filter kept from the file for this reference is replaced. @@ -352,12 +396,10 @@ pub(crate) fn prepare(d: &Drawing) -> Option { } }; let (fd, sf) = (fresh(&mut next), fresh(&mut next)); - let flag = clip.inverted.then(|| fresh(&mut next)); + // An inverted clip's filter has an extension dictionary and the record in it. + let marks = clip.inverted.then(|| (fresh(&mut next), fresh(&mut next))); if let Some(o) = idx.get(&xd).copied().and_then(|i| f.objects.get_mut(i)) { o.tags.extend([raw(3, "ACAD_FILTER"), raw(360, fd.hex())]); - if let Some(x) = flag { - o.tags.extend([raw(3, INVERT_KEY), raw(360, x.hex())]); - } } let mut filters = dictionary(fd, xd); filters.tags.extend([raw(3, "SPATIAL"), raw(360, sf.hex())]); @@ -366,9 +408,13 @@ pub(crate) fn prepare(d: &Drawing) -> Option { f.objects.push(o); }; add(filters); - add(spatial_filter(sf, fd, &clip, &to_world, &inverse)); - if let Some(x) = flag { - add(invert_record(x, xd)); + add(spatial_filter(sf, fd, &clip, &to_world, &inverse, marks.map(|(x, _)| x))); + if let Some((x, r)) = marks { + let mut own = dictionary(x, sf); + own.tags.extend([raw(3, ROUNDTRIP_KEY), raw(360, r.hex())]); + add(own); + let world: Vec = clip.points.iter().map(|p| to_world.apply(*p)).collect(); + add(roundtrip_record(r, x, &world)); } } f.objects.retain(|o| !gone.contains(&o.handle)); @@ -395,11 +441,11 @@ mod tests { #[test] fn hostile_filters_stay_foreign() { let o = |tags: Vec| ForeignObject { handle: Handle(1), kind: "SPATIAL_FILTER".into(), tags }; - assert!(parse_filter(&o(vec![raw(70, 1_000_000_000)])).is_none()); - assert!(parse_filter(&o(vec![raw(70, 3), raw(10, 0), raw(20, 0)])).is_none()); - assert!(parse_filter(&o(vec![raw(70, 2), raw(10, "nan"), raw(20, 0), raw(10, 1), raw(20, 1)])).is_none()); - assert!(parse_filter(&o(vec![raw(70, 2), raw(10, 0), raw(20, 0), raw(10, 1), raw(20, 1), raw(72, 1)])).is_none()); - let ok = parse_filter(&o(vec![raw(70, 2), raw(10, 0), raw(20, 0), raw(10, 1), raw(20, 1)])).unwrap(); + assert!(parse_filter(&o(vec![raw(70, 1_000_000_000)]), None).is_none()); + assert!(parse_filter(&o(vec![raw(70, 3), raw(10, 0), raw(20, 0)]), None).is_none()); + assert!(parse_filter(&o(vec![raw(70, 2), raw(10, "nan"), raw(20, 0), raw(10, 1), raw(20, 1)]), None).is_none()); + assert!(parse_filter(&o(vec![raw(70, 2), raw(10, 0), raw(20, 0), raw(10, 1), raw(20, 1), raw(72, 1)]), None).is_none()); + let ok = parse_filter(&o(vec![raw(70, 2), raw(10, 0), raw(20, 0), raw(10, 1), raw(20, 1)]), None).unwrap(); assert_eq!((ok.points.len(), ok.inverted), (2, false)); } @@ -418,15 +464,69 @@ mod tests { } #[test] - fn group_290_is_the_inverted_flag() { + fn group_290_is_read_but_never_written() { let tags = |invert: &str| vec![raw(70, 2), raw(10, 0), raw(20, 0), raw(10, 1), raw(20, 1), raw(290, invert)]; let o = |invert| ForeignObject { handle: Handle(1), kind: "SPATIAL_FILTER".into(), tags: tags(invert) }; - assert!(parse_filter(&o("1")).unwrap().inverted); - assert!(!parse_filter(&o("0")).unwrap().inverted); - assert!(!parse_filter(&o("junk")).unwrap().inverted); + assert!(parse_filter(&o("1"), None).unwrap().inverted); + assert!(!parse_filter(&o("0"), None).unwrap().inverted); + assert!(!parse_filter(&o("junk"), None).unwrap().inverted); + // The reference app discards a DXF whose filter has 290. let clip = ClipBoundary { inverted: true, ..ClipBoundary::new(vec![Vec2::ZERO, Vec2::new(1.0, 1.0)]) }; - let written = spatial_filter(Handle(2), Handle(1), &clip, &Mat3::IDENTITY, &Mat3::IDENTITY); - assert!(written.tags.iter().any(|t| t.code == 290 && t.value == "1")); - assert!(parse_filter(&written).unwrap().inverted); + let written = spatial_filter(Handle(2), Handle(1), &clip, &Mat3::IDENTITY, &Mat3::IDENTITY, Some(Handle(3))); + assert!(!written.tags.iter().any(|t| t.code == 290)); + assert_eq!(own_xdict(&written.tags), Some(Handle(3))); + } + + /// The filter, its extension dictionary and the reference app's record, as one foreign set. + fn marked(filter_points: &[(f64, f64)], boundary: &[(f64, f64)], compare: &[(f64, f64)]) -> (Foreign, ForeignObject) { + let mut tags = vec![raw(5, "10"), raw(102, "{ACAD_XDICTIONARY"), raw(360, "11"), raw(102, "}"), raw(70, filter_points.len())]; + for (x, y) in filter_points { + tags.extend([raw(10, x), raw(20, y)]); + } + let filter = ForeignObject { handle: Handle(0x10), kind: "SPATIAL_FILTER".into(), tags }; + let mut f = Foreign::default(); + let mut dict = dictionary(Handle(0x11), Handle(0x10)); + dict.tags.extend([raw(3, ROUNDTRIP_KEY), raw(360, "12")]); + f.objects.push(dict); + let mut rec = vec![raw(100, "AcDbXrecord"), raw(280, 1)]; + for (name, list) in [(ROUNDTRIP, boundary), (COMPARE, compare)] { + rec.push(raw(102, name)); + for (x, y) in list { + rec.extend([raw(10, x), raw(20, y), raw(30, 0)]); + } + } + f.objects.push(ForeignObject { handle: Handle(0x12), kind: "XRECORD".into(), tags: rec }); + f.objects.push(filter.clone()); + (f, filter) + } + + #[test] + fn the_reference_apps_record_marks_an_inverted_clip() { + // As the reference app writes an inverted rectangle: a keyhole filter, the rectangle kept. + let keyhole = [ + (0.0, 0.0), + (100.0, 0.0), + (100.0, 50.0), + (0.0, 50.0), + (0.0, 10.0), + (20.0, 10.0), + (20.0, 40.0), + (80.0, 40.0), + (80.0, 10.0), + (20.0, 10.0), + (0.0, 10.0), + ]; + let (f, filter) = marked(&keyhole, &[(20.0, 10.0), (80.0, 40.0)], &keyhole); + let boundary = roundtrip(&f, &index(&f), &filter).unwrap(); + let clip = parse_filter(&filter, Some(&boundary)).unwrap(); + assert!(clip.inverted); + assert_eq!(clip.points, vec![Vec2::new(20.0, 10.0), Vec2::new(80.0, 40.0)]); + // The simpler form: the filter keeps the boundary itself. + let rect = [(20.0, 10.0), (80.0, 40.0)]; + let (f, filter) = marked(&rect, &rect, &rect); + assert!(roundtrip(&f, &index(&f), &filter).is_some()); + // A filter changed since the record was made (the clip edited elsewhere) isn't inverted. + let (f, filter) = marked(&[(0.0, 0.0), (5.0, 5.0)], &rect, &rect); + assert!(roundtrip(&f, &index(&f), &filter).is_none()); } } diff --git a/crates/io/tests/it/xclip_roundtrip.rs b/crates/io/tests/it/xclip_roundtrip.rs index 727165d6c..a7df6c62b 100644 --- a/crates/io/tests/it/xclip_roundtrip.rs +++ b/crates/io/tests/it/xclip_roundtrip.rs @@ -77,13 +77,15 @@ fn clips_round_trip_through_dxf() { let bytes = save(&d, "a.dxf", Version::R2018); let text = String::from_utf8(bytes.clone()).unwrap(); assert_eq!(text.matches("SPATIAL_FILTER").count(), 3, "one filter per clipped reference"); - // The inverted triangle also carries CADCraft's flag, which a DWG keeps. - assert!(text.contains("ACAD_FILTER") && text.matches("CADCRAFT_XCLIP").count() == 1); - // The inverted triangle is marked on its filter (group 290, last), as the reference app writes it. + assert!(text.contains("ACAD_FILTER")); + // No filter has group 290: the reference app discards a DXF whose filter has one. The + // inverted triangle's filter has its record instead, in the filter's extension dictionary. let filters: Vec<&str> = text.split("\r\nSPATIAL_FILTER\r\n").skip(1).map(|f| f.split("\r\n 0\r\n").next().unwrap_or("")).collect(); assert_eq!(filters.len(), 3); - assert!(filters.iter().all(|f| f.contains("\r\n290\r\n")), "every filter says whether it is inverted"); - assert_eq!(filters.iter().filter(|f| f.ends_with("\r\n290\r\n1")).count(), 1, "{filters:?}"); + assert!(!filters.iter().any(|f| f.contains("\r\n290\r\n")), "{filters:?}"); + assert_eq!(filters.iter().filter(|f| f.contains("{ACAD_XDICTIONARY")).count(), 1, "{filters:?}"); + assert_eq!(text.matches("ACAD_XREC_ROUNDTRIP").count(), 1); + assert!(text.contains("ACAD_INVERTEDCLIP_ROUNDTRIP\r\n") && text.contains("ACAD_INVERTEDCLIP_ROUNDTRIP_COMPARE\r\n")); let first = read(&bytes, "a.dxf").unwrap(); same(&clips(&first), &clips(&d)); // The model owns the filters now: nothing of them is left as foreign data. @@ -95,6 +97,7 @@ fn clips_round_trip_through_dxf() { assert!(!back.foreign.objects.iter().any(|o| o.kind == "SPATIAL_FILTER"), "{}", v.year()); let again = String::from_utf8(save(&back, "a.dxf", v)).unwrap(); assert_eq!(again.matches("SPATIAL_FILTER").count(), 3, "no duplicate filters on resave"); + assert_eq!(again.matches("ACAD_XREC_ROUNDTRIP").count(), 1, "no duplicate records on resave"); } } @@ -124,7 +127,7 @@ fn deleting_a_clip_read_from_a_file_removes_its_filter() { .unwrap(); } let text = String::from_utf8(save(&d, "a.dxf", Version::R2018)).unwrap(); - assert!(!text.contains("SPATIAL_FILTER") && !text.contains("CADCRAFT_XCLIP")); + assert!(!text.contains("SPATIAL_FILTER") && !text.contains("ACAD_XREC_ROUNDTRIP")); assert!(clips(&read(text.as_bytes(), "a.dxf").unwrap()).iter().all(Option::is_none)); }