From e90a19736577ccbd815574a6f2291e9c19cd98b5 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Tue, 3 Nov 2026 18:07:00 -0700 Subject: [PATCH] XCLIP: keep CADCraft's inverted flag beside group 290 for DWG, whose bridge drops 290; the flag is read only when 290 is absent --- crates/engine/tests/it/xclip.rs | 4 +-- crates/io/src/dxf_clip.rs | 49 +++++++++++++++++++++++++-- crates/io/tests/it/xclip_roundtrip.rs | 8 +++-- crates/render/src/viewports.rs | 8 +++++ crates/render/tests/raster_image.rs | 1 + 5 files changed, 62 insertions(+), 8 deletions(-) diff --git a/crates/engine/tests/it/xclip.rs b/crates/engine/tests/it/xclip.rs index 664acd0bd..603640bc1 100644 --- a/crates/engine/tests/it/xclip.rs +++ b/crates/engine/tests/it/xclip.rs @@ -90,11 +90,11 @@ fn extents_are_the_boundary_box_as_in_the_reference_app() { // A boundary reaching past the block (0..100 x -50..50): its own box, not the overlap. s.execute("xclip", &json!({ "handle": r1, "rectangle": [[50, -20], [150, 30]] })).unwrap(); let b = bounds(&s); - assert!((b.min - Vec2::new(50.0, -20.0)).length() < 1e-9 && (b.max - Vec2::new(150.0, 30.0)).length() < 1e-9, "{b:?}"); + assert!((b.min - Vec2::new(50.0, -20.0)).len() < 1e-9 && (b.max - Vec2::new(150.0, 30.0)).len() < 1e-9, "{b:?}"); // One that misses the block entirely: still its box. s.execute("xclip", &json!({ "handle": r1, "rectangle": [[200, 200], [300, 300]] })).unwrap(); let b = bounds(&s); - assert!((b.min - Vec2::new(200.0, 200.0)).length() < 1e-9 && (b.max - Vec2::new(300.0, 300.0)).length() < 1e-9, "{b:?}"); + assert!((b.min - Vec2::new(200.0, 200.0)).len() < 1e-9 && (b.max - Vec2::new(300.0, 300.0)).len() < 1e-9, "{b:?}"); } #[test] diff --git a/crates/io/src/dxf_clip.rs b/crates/io/src/dxf_clip.rs index 02614b784..dedc1fd17 100644 --- a/crates/io/src/dxf_clip.rs +++ b/crates/io/src/dxf_clip.rs @@ -6,6 +6,10 @@ //! 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. +//! //! 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 //! foreign and are written back untouched. Writing adds the dictionaries and the filter of every @@ -18,6 +22,8 @@ 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"; /// Handles above this are not handed out (the DXF handle space is 64-bit, keep a margin). const MAX_HANDLE: u64 = 1 << 48; @@ -124,6 +130,14 @@ fn parse_filter(o: &ForeignObject) -> Option { (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, xd: Handle) -> bool { + object(f, xd) + .and_then(|o| entry(&o.tags, INVERT_KEY)) + .and_then(|h| object(f, h)) + .is_some_and(|o| o.kind.eq_ignore_ascii_case("XRECORD") && number(&o.tags, 70).is_some_and(|v| v != 0.0)) +} + /// Put the planar filters of the file read into `d` on their references, taking them out of /// the kept foreign data (the model writes them from now on). pub(crate) fn read(d: &mut Drawing) { @@ -135,8 +149,12 @@ pub(crate) fn read(d: &mut Drawing) { .entities .keys() .filter_map(|h| { - let (_, _, o) = chain(&d.foreign, *h)?; - Some((*h, parse_filter(o)?)) + let (xd, _, o) = chain(&d.foreign, *h)?; + let mut clip = parse_filter(o)?; + if number(&o.tags, 290).is_none() { + clip.inverted = inverted_record(&d.foreign, xd); + } + Some((*h, clip)) }) .collect(); if found.is_empty() { @@ -173,13 +191,14 @@ 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, -/// and the extension dictionary when nothing else is left in it. +/// CADCraft's inverted flag, and the extension dictionary when nothing else is left in it. fn forget(f: &mut Foreign, h: Handle) { let Some(xd) = f.entities.get(&h).and_then(|x| x.xdict) else { return }; let mut gone: Vec = Vec::new(); let mut empty = false; if let Some(o) = f.objects.iter_mut().find(|o| o.handle == xd) { gone.extend(remove_entry(&mut o.tags, "ACAD_FILTER")); + gone.extend(remove_entry(&mut o.tags, INVERT_KEY)); empty = !o.tags.iter().any(|t| t.code == 3); } // The filter dictionary's entries go with it. @@ -252,6 +271,23 @@ fn spatial_filter(h: Handle, owner: Handle, clip: &ClipBoundary, to_world: &Mat3 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), + ], + } +} + /// A copy of `d` whose foreign data holds the SPATIAL_FILTER of every clipped reference (any /// filter kept from the file for it is replaced). `None` when no reference is clipped. pub(crate) fn prepare(d: &Drawing) -> Option { @@ -296,13 +332,20 @@ pub(crate) fn prepare(d: &Drawing) -> Option { } }; let (fd, sf) = (fresh(&mut next), fresh(&mut next)); + let flag = clip.inverted.then(|| fresh(&mut next)); if let Some(o) = f.objects.iter_mut().find(|o| o.handle == xd) { 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())]); f.objects.push(filters); f.objects.push(spatial_filter(sf, fd, &clip, &to_world, &inverse)); + if let Some(x) = flag { + f.objects.push(invert_record(x, xd)); + } } Some(out) } diff --git a/crates/io/tests/it/xclip_roundtrip.rs b/crates/io/tests/it/xclip_roundtrip.rs index 762ac1700..727165d6c 100644 --- a/crates/io/tests/it/xclip_roundtrip.rs +++ b/crates/io/tests/it/xclip_roundtrip.rs @@ -77,11 +77,13 @@ 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"); - assert!(text.contains("ACAD_FILTER") && !text.contains("CADCRAFT_XCLIP")); - // The inverted triangle is marked on its filter (group 290), as the reference app writes it. + // 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. 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.contains("\r\n290\r\n1\r\n")).count(), 1); + assert_eq!(filters.iter().filter(|f| f.ends_with("\r\n290\r\n1")).count(), 1, "{filters:?}"); 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. diff --git a/crates/render/src/viewports.rs b/crates/render/src/viewports.rs index 6ec10d261..5dbc496c5 100644 --- a/crates/render/src/viewports.rs +++ b/crates/render/src/viewports.rs @@ -309,6 +309,14 @@ fn push_clipped_image(dst: &mut DisplayList, p: &DPrim, img: &DImage, corners: & let (outline, area) = match clip { Clip::Rect(rect) => (vec![rect.min, Vec2::new(rect.max.x, rect.min.y), rect.max, Vec2::new(rect.min.x, rect.max.y)], *rect), Clip::Poly(c) => (c.polygon().to_vec(), c.bounds()), + // An image can only be cut to the inside of an outline: under an inverted XCLIP it is + // drawn whole, which shows too much rather than losing any of it. + Clip::Outside(_) => { + let Ok(index) = u32::try_from(dst.images.len()) else { return }; + dst.images.push(img.clone()); + push_raw(dst, p, Kind::Image { index }, corners); + return; + } }; let own = Bounds2::from_points(corners.iter().copied()); if !own.intersects(&area) { diff --git a/crates/render/tests/raster_image.rs b/crates/render/tests/raster_image.rs index bdf03dd44..03016e79d 100644 --- a/crates/render/tests/raster_image.rs +++ b/crates/render/tests/raster_image.rs @@ -219,6 +219,7 @@ fn an_image_in_a_block_follows_the_block_reference() { rows: 1, col_spacing: 0.0, row_spacing: 0.0, + clip: None, }; let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(ins.clone())).unwrap(); d.rasters.insert("map.png", pixels());