diff --git a/Cargo.lock b/Cargo.lock index 4337ad057..b945ae3d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "acadrust" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1db2bd3da02ed787f3f5c8afba8779dd104b4459b78bb1431b1a0211cc3719f0" +checksum = "7df99ae4f5a6d0147907f9976fe1dd166d01e72ab1a93820b50242937c5797e6" dependencies = [ "anyhow", "bitflags 2.13.2", diff --git a/crates/dwg/Cargo.toml b/crates/dwg/Cargo.toml index 0f1d6aaec..bf2ad34d0 100644 --- a/crates/dwg/Cargo.toml +++ b/crates/dwg/Cargo.toml @@ -10,4 +10,4 @@ workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # MPL-2.0: used as an unmodified dependency only (see plan/adr/0001 and NOTICE). -acadrust = "0.6.1" +acadrust = "0.6.3" diff --git a/crates/dwg/src/lib.rs b/crates/dwg/src/lib.rs index 50d6bd425..d19027744 100644 --- a/crates/dwg/src/lib.rs +++ b/crates/dwg/src/lib.rs @@ -104,12 +104,22 @@ mod native { } } - /// Convert DXF bytes into a DWG file. + /// Oldest DWG version written; older DXF input is written as this version. + const MIN_DWG_VERSION: acadrust::DxfVersion = acadrust::DxfVersion::AC1018; + + /// Convert DXF bytes into a DWG file of the DXF's version, and at least AutoCAD 2004 + /// (AC1018). pub fn dxf_to_dwg(dxf: &[u8]) -> Result, String> { let data = dxf.to_vec(); - let doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read())) + let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read())) .map_err(|_| "the DXF→DWG conversion failed".to_string())? .map_err(|e| format!("DXF: {e}"))?; + // Write at least AutoCAD 2004 (AC1018) DWG: R2000 DWG has no true colours, transparency, + // table styles or gradients, and readers can't tell its layouts apart reliably. The DXF + // stays R2000; only the DWG is newer. + if doc.version < MIN_DWG_VERSION { + doc.version = MIN_DWG_VERSION; + } acadrust::DwgWriter::write_to_vec(&doc).map_err(|e| format!("DWG write: {e}")) } } diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs index e973a01a0..ac603a468 100644 --- a/crates/io/src/dxf_ext.rs +++ b/crates/io/src/dxf_ext.rs @@ -306,6 +306,15 @@ pub(crate) fn read_dstyle(tags: &[Tag], styles: &HashMap, blocks out } +/// Xdata marker of the paper-space viewport (id 1) the writer adds to layouts that have none; +/// CADCraft keeps that viewport implicit, so the reader drops it. +pub(crate) const PAPER_VIEW: &str = "PAPERVIEW"; + +/// True for a VIEWPORT record carrying the [`PAPER_VIEW`] marker. +pub(crate) fn is_paper_view(tags: &[Tag]) -> bool { + xdata(tags, APP).iter().any(|t| t.code == 1000 && t.str().trim().eq_ignore_ascii_case(PAPER_VIEW)) +} + /// Snap kinds in CADCraft's ASSOC xdata. fn snap_code(s: &AssocSnap) -> i64 { match s { diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 9d1b38cc8..16c0a18c1 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -122,6 +122,10 @@ fn text_from(t: &T) -> Text { /// Parse one entity record. POLYLINE/INSERT followers are handled by the caller. fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { + // The paper-space viewport our writer adds (dxf_write::paper_view) stays implicit. + if kind == "VIEWPORT" && crate::dxf_ext::is_paper_view(tags) { + return None; + } let t = T(tags); let c = common(&t); let k = match kind { diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 6a426c331..1ba51ba68 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -92,6 +92,9 @@ struct OsnapRef { /// Handles and names the entity writer needs from the rest of the file. #[derive(Default)] struct Ctx { + /// First viewport of each layout that has no paper-space viewport (id 1) → the layout's index; + /// [`paper_view`] writes that layout's paper-space viewport ahead of it. + paper_views: HashMap, /// Dimension → anonymous `*D` block name. dim_blocks: HashMap, /// Upper-case text style name → STYLE record handle. @@ -808,16 +811,19 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx hatch_xdata(w, h); } EntityKind::Viewport(v) => { + paper_view(w, d, e, owner, cx); w.s(0, "VIEWPORT"); common(w, e, owner, paper, "AcDbViewport"); w.p(10, v.center); w.f(40, v.width); w.f(41, v.height); + // Status 68: a positive value is "on" (the stacking order; 1 is the active viewport). + w.i(68, i64::from(v.id.clamp(1, 32767))); w.i(69, i64::from(v.id)); w.p2(12, v.view_center); w.f(45, v.view_height); - // Status flags: 16384 = display locked. - w.i(90, if v.locked { 16384 } else { 0 }); + // Status flags: 16384 = display locked, 32768 = always set (readers take it as "on"). + w.i(90, 32768 | if v.locked { 16384 } else { 0 }); // One CADCraft xdata group holds both lists: readers stop at the first group of an app. if !v.frozen_layers.is_empty() || !v.layer_colors.is_empty() { w.s(1001, dxf_ext::APP); @@ -1091,6 +1097,56 @@ fn std_assoc_refs(d: &Drawing, dm: &Dimension) -> (i64, Vec) { (flags, refs) } +/// Layouts with viewports but without the paper-space viewport (id 1, the sheet itself), keyed +/// by their first viewport. AutoCAD and DWG take a layout's first viewport as its paper-space +/// viewport (DWG does not store viewport ids; readers number them in order), so a layout made in +/// CADCraft, whose paper-space viewport is implicit, gets one written ahead of its viewports. +fn paper_views(d: &Drawing) -> HashMap { + let mut out = HashMap::new(); + 'layouts: for (i, l) in d.layouts.iter().enumerate() { + let mut first = None; + for e in l.entities.iter() { + if let EntityKind::Viewport(v) = &e.kind { + if v.id == 1 { + continue 'layouts; + } + first.get_or_insert(e.handle); + } + } + if let Some(h) = first { + out.insert(h, i); + } + } + out +} + +/// The paper-space viewport (id 1) of the layout whose first viewport is `e`, if it needs one +/// ([`paper_views`]): the whole sheet, viewed 1:1. Marked with CADCraft xdata so that our reader +/// leaves it implicit again. +fn paper_view(w: &mut W, d: &Drawing, e: &Entity, owner: &str, cx: &Ctx) { + let Some(l) = cx.paper_views.get(&e.handle).and_then(|i| d.layouts.get(*i)) else { return }; + let size = cadcraft_render::paper::sheet(d, &l.name).map(|s| s.size).unwrap_or(Vec2::new(12.0, 9.0)); + let center = size * 0.5; + let h = w.h(); + w.s(0, "VIEWPORT"); + w.s(5, h); + w.s(330, owner); + w.s(100, "AcDbEntity"); + w.i(67, 1); + w.s(8, "0"); + w.s(100, "AcDbViewport"); + w.p(10, center.to3(0.0)); + w.f(40, size.x); + w.f(41, size.y); + w.i(68, 1); + w.i(69, 1); + w.p2(12, center); + w.f(45, size.y); + w.i(90, 32768); + w.s(1001, dxf_ext::APP); + w.s(1000, dxf_ext::PAPER_VIEW); +} + fn table_head(w: &mut W, name: &str, count: usize) -> String { let h = w.h(); w.s(0, "TABLE"); @@ -1129,7 +1185,7 @@ pub fn write(d: &Drawing) -> String { let mut user_blocks: Vec<(String, String, &Block)> = d.blocks.values().map(|b| (b.name.clone(), String::new(), b.as_ref())).collect(); user_blocks.iter_mut().for_each(|b| b.1 = format!("{:X}", 0)); let user_blocks: Vec<(String, String, &Block)> = user_blocks.into_iter().map(|(n, _, b)| (n, w.h(), b)).collect(); - let mut cx = Ctx::default(); + let mut cx = Ctx { paper_views: paper_views(d), ..Ctx::default() }; // Dimension blocks. let mut dim_defs: Vec<(String, String, Vec)> = Vec::new(); // filled below let mut n = 1; diff --git a/crates/io/tests/dwg_roundtrip_losses.rs b/crates/io/tests/dwg_roundtrip_losses.rs new file mode 100644 index 000000000..a5112cfd4 --- /dev/null +++ b/crates/io/tests/dwg_roundtrip_losses.rs @@ -0,0 +1,139 @@ +//! Data that used to be lost only when a drawing is saved as DWG and reopened (the DWG bridge +//! converts our DXF with acadrust), and the paper-space viewport the DXF writer adds for it. +#![cfg(not(target_arch = "wasm32"))] + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::*; +use cadcraft_geom::{Vec2, Vec3}; + +fn through_dwg(d: &Drawing) -> Drawing { + let dwg = cadcraft_io::write(d, "x.dwg").unwrap(); + cadcraft_io::read(&dwg, "x.dwg").unwrap() +} + +fn line(d: &mut Drawing, space: &Space, common: Common) -> Handle { + d.add(space, common, EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(10.0, 5.0, 0.0) })).unwrap() +} + +fn viewport(id: u32) -> EntityKind { + EntityKind::Viewport(Viewport { + center: Vec3::new(50.0, 40.0, 0.0), + width: 80.0, + height: 60.0, + view_center: Vec2::new(10.0, 10.0), + view_height: 50.0, + id, + locked: false, + frozen_layers: vec![], + layer_colors: vec![], + }) +} + +/// The (code, value) pairs of every `kind` record in DXF text. +fn records(text: &str, kind: &str) -> Vec> { + let lines: Vec<&str> = text.lines().map(str::trim).collect(); + let mut out: Vec> = Vec::new(); + let mut inside = false; + for pair in lines.chunks(2) { + let [code, value] = pair else { break }; + if *code == "0" { + inside = *value == kind; + if inside { + out.push(Vec::new()); + } + } else if inside && let Some(rec) = out.last_mut() { + rec.push((code.to_string(), value.to_string())); + } + } + out +} + +fn group<'a>(rec: &'a [(String, String)], code: &str) -> Option<&'a str> { + rec.iter().find(|(c, _)| c == code).map(|(_, v)| v.as_str()) +} + +fn viewport_ids(l: &Layout) -> Vec { + l.entities.iter().filter_map(|e| if let EntityKind::Viewport(v) = &e.kind { Some(v.id) } else { None }).collect() +} + +/// True colours (entity and layer) and entity transparency: the DWG is written as AutoCAD 2004 +/// (R2000 DWG has neither), while the DXF stays R2000. +#[test] +fn dwg_keeps_true_colours_and_transparency() { + let mut d = Drawing::new_metric(); + d.layers.push(Layer { name: "Fancy".into(), color: Color::True(Rgb(12, 200, 99)), ..Layer::default() }); + let h = line( + &mut d, + &Space::Model, + Common { layer: "Fancy".into(), color: Color::True(Rgb(1, 2, 3)), transparency: Transparency::Percent(40), ..Common::default() }, + ); + + let dwg = cadcraft_io::write(&d, "x.dwg").unwrap(); + assert_eq!(cadcraft_dwg::version(&dwg).as_deref(), Some("AC1018")); + let header = records(&cadcraft_io::write_dxf(&d), "SECTION"); + assert_eq!(header.first().and_then(|h| group(h, "1")), Some("AC1015"), "the DXF stays R2000"); + + let back = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + let e = back.entity(h).unwrap(); + assert_eq!(e.common.color, Color::True(Rgb(1, 2, 3))); + assert_eq!(e.common.transparency, Transparency::Percent(40)); + assert_eq!(back.layers.iter().find(|l| l.name == "Fancy").map(|l| l.color), Some(Color::True(Rgb(12, 200, 99)))); +} + +/// Entities of a layout other than the first stay in that layout. +#[test] +fn dwg_keeps_paper_space_entities_in_their_layout() { + let mut d = Drawing::new_metric(); + let second = Space::Paper(d.layouts[1].name.clone()); + let h = d.add(&second, Common::default(), EntityKind::Circle(Circle { center: Vec3::new(1.0, 1.0, 0.0), radius: 0.5 })).unwrap(); + let back = through_dwg(&d); + assert!(back.layouts[1].entities.contains(h), "{:?}", back.layouts.iter().map(|l| l.entities.len()).collect::>()); + assert!(!back.layouts[0].entities.contains(h)); +} + +/// Table styles (R2000 DWG has no TABLESTYLE objects). +#[test] +fn dwg_keeps_table_styles() { + let mut d = Drawing::new_metric(); + let sched = TableStyle { name: "Sched".into(), text_height: 2.5, margin: 1.0, title: false, header: true }; + d.table_styles.push(sched.clone()); + let back = through_dwg(&d); + assert_eq!(back.table_styles.iter().find(|s| s.name == "Sched"), Some(&sched)); +} + +/// DWG doesn't store viewport ids; readers number a layout's viewports in order, and id 1 is the +/// paper-space viewport (the sheet, not drawn). The writer puts that viewport ahead of a layout's +/// own viewports, so a viewport made in CADCraft (id 2) keeps its id instead of becoming the +/// sheet; our reader drops the added viewport again, so a DXF round trip is unchanged. +#[test] +fn viewport_ids_survive_dwg_and_the_paper_viewport_stays_implicit() { + let mut d = Drawing::new_metric(); + let first = Space::Paper(d.layouts[0].name.clone()); + let h = d.add(&first, Common::default(), viewport(2)).unwrap(); + d.add(&first, Common::default(), viewport(3)).unwrap(); + + let text = cadcraft_io::write_dxf(&d); + let vps = records(&text, "VIEWPORT"); + assert_eq!(vps.len(), 3, "{text}"); + assert_eq!(group(&vps[0], "69"), Some("1")); + assert_eq!(group(&vps[0], "1000"), Some("PAPERVIEW")); + // Viewports are written "on": status 68 positive, and 90 with the always-set bit 32768. + assert_eq!(group(&vps[1], "68"), Some("2")); + assert_eq!(group(&vps[1], "90"), Some("32768")); + + let back = cadcraft_io::read_dxf(text.as_bytes()).unwrap(); + assert_eq!(viewport_ids(&back.layouts[0]), [2, 3]); + assert_eq!(back.layouts[0].entities.len(), 2); + + let back = through_dwg(&d); + assert_eq!(viewport_ids(&back.layouts[0]), [2, 3]); + assert!(back.layouts[0].entities.contains(h)); + + // A layout that has its paper-space viewport (from another program) gets no second one. + let mut d = Drawing::new_metric(); + d.add(&first, Common::default(), viewport(1)).unwrap(); + d.add(&first, Common::default(), viewport(2)).unwrap(); + let text = cadcraft_io::write_dxf(&d); + assert!(!text.contains("PAPERVIEW")); + assert_eq!(viewport_ids(&cadcraft_io::read_dxf(text.as_bytes()).unwrap().layouts[0]), [1, 2]); +}