mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
* 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:
5 files changed
+273
-2
No files matched your search
@@ -25,3 +25,6 @@ sync_github_name = false
|
||||
[people.Tru10]
|
||||
display_name = "by Truitt"
|
||||
sync_github_name = false
|
||||
|
||||
[people.vangel76]
|
||||
sync_github_name = true
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user