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 <[email protected]>

* contributors: add vangel76

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Gradient test: expect \U+ escapes now that non-ASCII text is escaped (#169)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-10 13:14:52 -04:00
1 parent 8fceaaff3a
commit f0d6f248f7
5 files changed
+273 -2

No files matched your search

+3
View File
@@ -25,3 +25,6 @@ sync_github_name = false
[people.Tru10]
display_name = "by Truitt"
sync_github_name = false
[people.vangel76]
sync_github_name = true
+2 -1
View File
@@ -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
+67 -1
View File
@@ -524,12 +524,78 @@ fn hatch(tags: &[Tag], t: &T) -> Option<EntityKind> {
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<Gradient> {
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<Gradient> {
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<String> = 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<Gradient> {
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<Color>, Option<Rgb>); 2] = [(None, None); 2];
let mut slot: Option<usize> = 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<Color>, Option<Rgb>), 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 {
+38
View File
@@ -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<Entity> {
let style = d.dim_style(&dm.style).cloned().unwrap_or_default();
+163
View File
@@ -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<Option<Gradient>>) {
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<Hatch> {
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::<Vec<_>>(), back.model.iter().map(|e| &e.kind).collect::<Vec<_>>());
}
/// 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::<i32>().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::<Vec<_>>(), 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::<Vec<_>>(), gradients);
assert_eq!(hs.iter().map(|h| h.loops.clone()).collect::<Vec<_>>(), hatches(&d).into_iter().map(|h| h.loops).collect::<Vec<_>>());
}
#[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::<Vec<_>>(),
hatches(&d).into_iter().map(|h| h.gradient).collect::<Vec<_>>()
);
}
}
/// 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);
}