From f0d6f248f70a21a90bfc0a80963d21b0592e587f Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 19:14:52 +0200 Subject: [PATCH] DXF/DWG: keep hatch gradients through save and reopen (fixes #80) (#162) * DXF/DWG: keep hatch gradients through save and reopen (fixes #80) The HATCH writer never emitted a gradient and the reader always set `gradient: None`, so a GRADIENT fill reopened as a plain SOLID hatch after any DXF or DWG save. - Writer: native HATCH groups per the DXF Reference (450 gradient flag, 451, 452 two-colour, 453 colour count, 460 angle in radians, 461 shift (0 = centered), 462 tint, 463 per-colour records with 63 ACI and 421 true colour, 470 name), plus a `CADCRAFT` `GRADIENT` xdata copy (name cut to 255 bytes, angle, centered, colour names). - Reader: native groups win when present (another program may have edited them); otherwise the xdata copy is used. The xdata is what survives DWG saves: the file is R2000 (AC1015), whose DWG HATCH has no gradient fields, so the DWG bridge drops the native groups. - Hostile values (NaN angles, out-of-range colours, extra colour records, missing groups) fall back to the GRADIENT command's defaults. Tests: gradient_hatch_roundtrips_through_dxf, gradient_hatch_roundtrips_through_dwg, gradient_falls_back_to_xdata_and_native_groups_win, long_gradient_name_is_cut_only_in_xdata, hostile_gradient_groups_never_panic. Co-Authored-By: Claude Opus 5.5 * contributors: add vangel76 Co-Authored-By: Claude Opus 5.5 * Gradient test: expect \U+ escapes now that non-ASCII text is escaped (#169) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- contributors/people.toml | 3 + crates/io/src/dxf_ext.rs | 3 +- crates/io/src/dxf_read.rs | 68 +++++++++++++++- crates/io/src/dxf_write.rs | 38 +++++++++ crates/io/src/tests.rs | 163 +++++++++++++++++++++++++++++++++++++ 5 files changed, 273 insertions(+), 2 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 8ef40d3..114c3ad 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -25,3 +25,6 @@ sync_github_name = false [people.Tru10] display_name = "by Truitt" sync_github_name = false + +[people.vangel76] +sync_github_name = true diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs index a7af272..3adafab 100644 --- a/crates/io/src/dxf_ext.rs +++ b/crates/io/src/dxf_ext.rs @@ -1,7 +1,8 @@ //! DXF data shared by the reader and the writer beyond plain entity geometry: //! dimension-variable group codes (DIMSTYLE records and the `ACAD` "DSTYLE" override xdata, //! both per the DXF Reference), arrowhead block names, extended-data helpers and the -//! CADCraft-owned payloads (exact associativity, table flags, parametric constraints). +//! CADCraft-owned payloads (exact associativity, table flags, hatch gradients, parametric +//! constraints). //! //! CADCraft's own data lives under the registered application `CADCRAFT` (xdata) and the //! named-object-dictionary entry `CADCRAFT_CONSTRAINTS` (an XRECORD). Other readers keep or diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index d9c6074..7fff942 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -524,12 +524,78 @@ fn hatch(tags: &[Tag], t: &T) -> Option { associative: t.i(71).unwrap_or(0) != 0, style: t.i(75).unwrap_or(0) as u8, elevation: 0.0, - gradient: None, + gradient: gradient(tags), origin: Vec2::ZERO, background: None, })) } +/// Gradient values missing from a file fall back to the GRADIENT command's defaults. +fn default_gradient() -> Gradient { + Gradient { name: "LINEAR".into(), color1: Color::Index(5), color2: Color::Index(7), angle: 0.0, centered: true } +} + +/// A hatch's gradient fill: the native groups when present (another program may have edited +/// them), otherwise CADCraft's `GRADIENT` xdata (kept by R2000 files and DWG conversions). +fn gradient(tags: &[Tag]) -> Option { + if tags.iter().any(|x| x.code == 450) { native_gradient(tags) } else { xdata_gradient(tags) } +} + +/// `CADCRAFT` xdata: `1000 GRADIENT`, then name, 1040 angle, 1070 centered, colour 1 and 2 names. +fn xdata_gradient(tags: &[Tag]) -> Option { + let x = crate::dxf_ext::xdata(tags, crate::dxf_ext::APP); + let at = x.iter().position(|t| t.code == 1000 && t.str() == "GRADIENT")?; + let o = x.get(at + 1..).unwrap_or(&[]); + let d = default_gradient(); + let strs: Vec = o.iter().filter(|t| t.code == 1000).take(3).map(Tag::str).collect(); + let color = |i: usize, d: Color| strs.get(i).and_then(|s| Color::parse(s)).unwrap_or(d); + Some(Gradient { + name: strs.first().cloned().unwrap_or(d.name), + color1: color(1, d.color1), + color2: color(2, d.color2), + angle: o.iter().find(|t| t.code == 1040).map(Tag::f64).filter(|a| a.is_finite()).unwrap_or(d.angle), + centered: o.iter().find(|t| t.code == 1070).map_or(d.centered, |t| t.i64() != 0), + }) +} + +/// DXF Reference HATCH groups 450–470, when 450 says gradient. Each 463 starts a colour record +/// whose 63 (ACI) and 421 (true colour) groups follow. +fn native_gradient(tags: &[Tag]) -> Option { + let start = tags.iter().position(|x| x.code == 450)?; + if tags.get(start)?.i64() != 1 { + return None; + } + let mut g = default_gradient(); + // Per colour record: (ACI, true colour); a true colour wins over its ACI fallback. + let mut colors: [(Option, Option); 2] = [(None, None); 2]; + let mut slot: Option = None; + for x in tags.iter().skip(start + 1).take_while(|x| x.code != 1001) { + let rec = slot.and_then(|s| colors.get_mut(s)); + match x.code { + 460 => g.angle = Some(x.f64()).filter(|a| a.is_finite()).unwrap_or(0.0), + 461 => g.centered = x.f64().abs() < 1e-9, + 463 => slot = Some(slot.map_or(0, |s| s.saturating_add(1))), + 63 => { + if let Some(r) = rec { + r.0 = Some(Color::from_aci(i16::try_from(x.i64()).unwrap_or(256))); + } + } + 421 => { + if let Some(r) = rec { + r.1 = Some(Rgb::from_u32(u32::try_from(x.i64() & 0xff_ffff).unwrap_or(0))); + } + } + 470 => g.name = x.str(), + _ => {} + } + } + let [c1, c2] = colors; + let pick = |(aci, rgb): (Option, Option), d: Color| rgb.map(Color::True).or(aci).unwrap_or(d); + g.color1 = pick(c1, g.color1); + g.color2 = pick(c2, g.color2); + Some(g) +} + /// Handle maps and references gathered while reading. #[derive(Default)] struct Rx { diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index b27a3c8..09c9785 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -777,6 +777,9 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx } } w.i(98, 0); + if let Some(g) = &h.gradient { + gradient(w, g); + } } EntityKind::Viewport(v) => { w.s(0, "VIEWPORT"); @@ -799,6 +802,41 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx } } +/// A hatch's gradient fill (DXF Reference, HATCH group codes 450–470): two-colour gradient +/// with its rotation (radians), shift (0 = centered, 1 = shifted) and per-colour 463 records +/// carrying ACI (63) and, for true colours, the RGB value (421). Those groups belong to R2004+ +/// files, so the same gradient also travels as `CADCRAFT` xdata, which survives this R2000 +/// file's conversion to DWG (where the native fields don't exist). +fn gradient(w: &mut W, g: &Gradient) { + w.i(450, 1); + w.i(451, 0); + w.f(460, g.angle); + w.f(461, if g.centered { 0.0 } else { 1.0 }); + w.i(452, 0); + w.f(462, 0.0); + w.i(453, 2); + for (k, c) in [(0.0, g.color1), (1.0, g.color2)] { + w.f(463, k); + match c { + Color::True(rgb) => { + w.i(63, i64::from(cadcraft_color::nearest_aci(rgb))); + w.i(421, i64::from(rgb.to_u32())); + } + c => w.i(63, i64::from(c.to_aci())), + } + } + w.s(470, &g.name); + w.s(1001, dxf_ext::APP); + w.s(1000, "GRADIENT"); + // A 1000 group holds at most 255 bytes; cut at a character boundary. + let cut = g.name.char_indices().map(|(i, c)| i + c.len_utf8()).take_while(|end| *end <= 255).last().unwrap_or(0); + w.s(1000, g.name.get(..cut).unwrap_or_default()); + w.f(1040, g.angle); + w.i(1070, i64::from(g.centered)); + w.s(1000, g.color1.name()); + w.s(1000, g.color2.name()); +} + /// Anonymous dimension blocks (`*D1`…) with the rendered geometry, as consumers expect. fn dim_block_entities(d: &Drawing, dm: &Dimension, layer: &str) -> Vec { let style = d.dim_style(&dm.style).cloned().unwrap_or_default(); diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 1daba59..febd26c 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -1312,3 +1312,166 @@ fn arc_length_dimension_keeps_its_kind_and_centre_on_roundtrip() { assert!(matches!(dims[0].kind, DimKind::ArcLength), "{:?}", dims[0].kind); assert!((dims[0].p15 - Vec3::new(5.0, 5.0, 0.0)).len() < 1e-9); } + +/// Two gradient hatches (true colours, shifted, rotated; index colours, centered) and a plain +/// solid hatch over the same square. +fn gradient_sample() -> (Drawing, Vec>) { + let mut d = Drawing::new_metric(); + let square = vec![ + PolyVertex::new(Vec2::ZERO), + PolyVertex::new(Vec2::new(10.0, 0.0)), + PolyVertex::new(Vec2::new(10.0, 10.0)), + PolyVertex::new(Vec2::new(0.0, 10.0)), + ]; + let gradients = vec![ + Some(Gradient { + name: "LINEAR".into(), + color1: Color::True(cadcraft_color::Rgb(255, 0, 0)), + color2: Color::True(cadcraft_color::Rgb(0, 0, 255)), + angle: 30f64.to_radians(), + centered: false, + }), + Some(Gradient { name: "CURVED".into(), color1: Color::Index(5), color2: Color::ByLayer, angle: 0.0, centered: true }), + None, + ]; + for g in &gradients { + let h = Hatch { + pattern: "SOLID".into(), + solid: true, + loops: vec![HatchLoop { vertices: square.clone(), outer: true }], + scale: 1.0, + angle: 0.0, + associative: false, + style: 0, + elevation: 0.0, + gradient: g.clone(), + origin: Vec2::ZERO, + background: None, + }; + d.add(&Space::Model, Common::default(), EntityKind::Hatch(h)).unwrap(); + } + (d, gradients) +} + +fn hatches(d: &Drawing) -> Vec { + d.model.iter().filter_map(|e| if let EntityKind::Hatch(h) = &e.kind { Some(h.clone()) } else { None }).collect() +} + +/// Issue #80: a DXF save/reopen keeps each hatch's gradient (name, both colours, angle and +/// centered setting) as native HATCH groups 450–470, and a plain solid stays plain. +#[test] +fn gradient_hatch_roundtrips_through_dxf() { + let (d, gradients) = gradient_sample(); + let text = write_dxf(&d); + for code in ["450", "451", "452", "453", "460", "461", "462", "463", "421", "470"] { + assert!(text.lines().any(|l| l.trim() == code), "missing group {code}"); + } + let back = read_dxf(text.as_bytes()).unwrap(); + let (a, b) = (hatches(&d), hatches(&back)); + assert_eq!(b.len(), gradients.len()); + for ((x, y), g) in a.iter().zip(&b).zip(&gradients) { + assert_eq!(&y.gradient, g); + assert_eq!(x.loops, y.loops); + } + assert_eq!(roundtrip(&back).model.iter().map(|e| &e.kind).collect::>(), back.model.iter().map(|e| &e.kind).collect::>()); +} + +/// R2000 readers and DWG conversions drop the native groups; CADCraft's xdata copy restores the +/// gradient. When native groups are present (another program may have edited them) they win. +#[test] +fn gradient_falls_back_to_xdata_and_native_groups_win() { + let (d, gradients) = gradient_sample(); + let text = write_dxf(&d); + // Strip groups 450–470 and 63/421 (pairs of lines) from every entity. + let lines: Vec<&str> = text.lines().collect(); + let mut stripped = String::new(); + for pair in lines.chunks(2) { + let code = pair.first().map(|c| c.trim().parse::().unwrap_or(-1)).unwrap_or(-1); + if (450..=470).contains(&code) || code == 63 || code == 421 { + continue; + } + for l in pair { + stripped.push_str(l); + stripped.push('\n'); + } + } + assert!(!stripped.lines().any(|l| l.trim() == "450")); + let back = read_dxf(stripped.as_bytes()).unwrap(); + assert_eq!(hatches(&back).into_iter().map(|h| h.gradient).collect::>(), gradients); + + // Native groups edited elsewhere (new angle, colour, name) take precedence over stale xdata. + let edited = text.replacen("470\r\nLINEAR", "470\r\nSPHERICAL", 1).replacen("421\r\n16711680", "421\r\n65280", 1); + assert_ne!(edited, text); + let back = read_dxf(edited.as_bytes()).unwrap(); + let g = hatches(&back).into_iter().next().and_then(|h| h.gradient).unwrap(); + assert_eq!(g.name, "SPHERICAL"); + assert_eq!(g.color1, Color::True(cadcraft_color::Rgb(0, 255, 0))); +} + +/// Issue #80: DWG saves (R2000, which has no native gradient fields) keep the gradient too. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn gradient_hatch_roundtrips_through_dwg() { + let (d, gradients) = gradient_sample(); + let back = read(&write(&d, "x.dwg").unwrap(), "x.dwg").unwrap(); + let hs = hatches(&back); + assert_eq!(hs.iter().map(|h| h.gradient.clone()).collect::>(), gradients); + assert_eq!(hs.iter().map(|h| h.loops.clone()).collect::>(), hatches(&d).into_iter().map(|h| h.loops).collect::>()); +} + +#[test] +fn hostile_gradient_groups_never_panic() { + let hatch = |tail: &str| { + format!( + "0\nSECTION\n2\nENTITIES\n0\nHATCH\n2\nSOLID\n70\n1\n91\n1\n92\n2\n72\n0\n73\n1\n93\n3\n10\n0\n20\n0\n10\n1\n20\n0\n10\n1\n20\n1\n98\n0\n{tail}0\nENDSEC\n0\nEOF\n" + ) + }; + let cases = [ + // Native: absurd counts, colour records past two, out-of-range ACI/true colours, NaN. + hatch( + "450\n1\n453\n2147483647\n460\nnan\n461\ninf\n463\n0\n63\n99999\n421\n-1\n463\n1\n63\n-32768\n421\n99999999999\n463\n2\n63\n3\n463\n7\n470\n\n", + ), + // Native flag only, or a non-gradient flag, or groups out of order. + hatch("450\n1\n"), + hatch("450\n-7\n470\nX\n"), + hatch("421\n255\n63\n1\n463\n0\n450\n1\n470\nLINEAR\n"), + // Xdata: missing values, junk colour names, huge numbers. + hatch("1001\nCADCRAFT\n1000\nGRADIENT\n"), + hatch("1001\nCADCRAFT\n1000\nGRADIENT\n1000\nX\n1040\n1e308\n1070\n-1\n1000\nnot a colour\n1000\n999,999,999\n"), + hatch("1001\nCADCRAFT\n1000\nGRADIENT\n1040\nnan\n1001\nACAD\n1000\n\n"), + ]; + for t in &cases { + let d = read(t.as_bytes(), "x.dxf").unwrap(); + for h in hatches(&d) { + if let Some(g) = &h.gradient { + assert!(g.angle.is_finite()); + } + } + let _ = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()); + let back = read_dxf(write_dxf(&d).as_bytes()).unwrap(); + assert_eq!( + hatches(&back).into_iter().map(|h| h.gradient).collect::>(), + hatches(&d).into_iter().map(|h| h.gradient).collect::>() + ); + } +} + +/// The native 470 group keeps a long name whole; the xdata copy is cut to 255 bytes at a +/// character boundary. +#[test] +fn long_gradient_name_is_cut_only_in_xdata() { + let name = "Ä".repeat(200); + let mut d = Drawing::new_metric(); + let mut h = hatches(&gradient_sample().0).into_iter().next().unwrap(); + if let Some(g) = &mut h.gradient { + g.name = name.clone(); + } + d.add(&Space::Model, Common::default(), EntityKind::Hatch(h)).unwrap(); + let text = write_dxf(&d); + let lines: Vec<&str> = text.lines().collect(); + // Non-ASCII text is written as \U+ escapes (R2000 files are ANSI). + assert!(lines.contains(&"\\U+00C4".repeat(200).as_str())); + assert!(lines.contains(&"\\U+00C4".repeat(127).as_str())); + let back = read_dxf(text.as_bytes()).unwrap(); + assert_eq!(hatches(&back).into_iter().next().and_then(|h| h.gradient).unwrap().name, name); +}