DXF: associative hatches in blocks keep their boundary; boundaries carry the hatch reactor (#871)

A hatch inside a block was written with no source objects (the writer looked
for them in model and paper space only), and no boundary object carried the
hatch as a persistent reactor. AutoCAD keeps the two linked both ways; without
it a stretched dynamic block in AutoCAD moved the outline but not the fill.

Co-authored-by: Cody Vogt <18035288+codyvogt@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
codyvogt
2026-10-13 04:46:29 -04:00
committed by GitHub
co-authored by Cody Vogt Claude Opus 5.5 Brandon Thomas
parent 5b037b8f92
commit 28b8160407
3 changed files with 116 additions and 4 deletions
+62 -3
View File
@@ -17,8 +17,15 @@ struct W {
next: u64,
/// Upper-case plot style name → its `ACDBPLACEHOLDER` handle (group 390 of layers and objects).
pstyles: HashMap<String, String>,
<<<<<<< HEAD
/// Upper-case layer name → its LAYER table record handle (group 341 of viewports).
layers: HashMap<String, String>,
||||||| 00c7613f
=======
/// Persistent reactors of boundary objects: each source object of an associative hatch →
/// the hatches (groups `{ACAD_REACTORS 330 …}`), from [`hatch_reactors`].
reactors: HashMap<Handle, Vec<String>>,
>>>>>>> pr/871
}
impl W {
@@ -160,6 +167,32 @@ impl Ctx {
}
}
/// The persistent reactors of boundary objects: each source object of an associative hatch in
/// the same space or block → the hatches it bounds (AutoCAD links the two both ways; without the
/// reactor the hatch no longer follows its boundary there).
fn hatch_reactors(d: &Drawing) -> HashMap<Handle, Vec<String>> {
let mut out: HashMap<Handle, Vec<String>> = HashMap::new();
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(d.blocks.values().map(|b| &b.entities));
for st in stores {
for e in st.iter() {
let EntityKind::Hatch(h) = &e.kind else { continue };
if !h.associative {
continue;
}
for s in h.loops.iter().flat_map(|l| &l.sources) {
if *s != e.handle && st.contains(*s) {
let r = out.entry(*s).or_default();
let own = e.handle.hex();
if !r.contains(&own) {
r.push(own);
}
}
}
}
}
out
}
/// `AcadAnnotative` xdata marking an annotative style.
fn annotative_xdata(w: &mut W) {
w.s(1001, "AcadAnnotative");
@@ -172,7 +205,13 @@ fn annotative_xdata(w: &mut W) {
/// Override (`ACAD` DSTYLE) and associativity (`CADCRAFT` ASSOC) xdata of a dimension.
fn dim_xdata(w: &mut W, dm: &Dimension, cx: &Ctx) {
<<<<<<< HEAD
let mut ov = W { t: Vec::new(), next: 0, pstyles: HashMap::new(), layers: HashMap::new() };
||||||| 00c7613f
let mut ov = W { t: Vec::new(), next: 0, pstyles: HashMap::new() };
=======
let mut ov = W { t: Vec::new(), next: 0, pstyles: HashMap::new(), reactors: HashMap::new() };
>>>>>>> pr/871
let mut sah = false;
for (k, v) in &dm.overrides {
let Some(field) = DimStyle::field_name(k) else { continue };
@@ -319,8 +358,13 @@ fn common(w: &mut W, e: &Entity, owner: &str, paper: bool, subclass: &str) {
/// Common entity groups; `assoc` adds the reactor and extension dictionary of a DIMASSOC.
fn common_x(w: &mut W, e: &Entity, owner: &str, paper: bool, subclass: &str, assoc: Option<&AssocObj>) {
w.s(5, e.handle.hex());
let mut reactors: Vec<String> = assoc.map(|a| a.handle.clone()).into_iter().collect();
reactors.extend(w.reactors.get(&e.handle).into_iter().flatten().cloned());
if !reactors.is_empty() {
let rs: Vec<&str> = reactors.iter().map(String::as_str).collect();
w.group("ACAD_REACTORS", 330, &rs);
}
if let Some(a) = assoc {
w.group("ACAD_REACTORS", 330, &[&a.handle]);
w.group("ACAD_XDICTIONARY", 360, &[&a.xdict]);
}
w.s(330, owner);
@@ -913,8 +957,11 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx
w.f(42, v.bulge);
}
}
// Source boundary objects of an associative hatch (the ones still in the drawing).
let sources: Vec<String> = l.sources.iter().filter(|s| h.associative && d.entity(**s).is_some()).map(|s| s.hex()).collect();
// Source boundary objects of an associative hatch (the ones still beside it, in
// its space or block, which carry the hatch as a reactor).
let own = e.handle.hex();
let sources: Vec<String> =
l.sources.iter().filter(|s| h.associative && w.reactors.get(*s).is_some_and(|r| r.contains(&own))).map(|s| s.hex()).collect();
w.i(97, sources.len() as i64);
for s in &sources {
w.s(330, s);
@@ -1474,7 +1521,13 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
let committed = d.committed();
let d: &Drawing = &committed;
// Structural handles start above every entity handle.
<<<<<<< HEAD
let mut w = W { t: Vec::new(), next: d.handseed.max(0x100).saturating_add(0x1000), pstyles: HashMap::new(), layers: HashMap::new() };
||||||| 00c7613f
let mut w = W { t: Vec::new(), next: d.handseed.max(0x100).saturating_add(0x1000), pstyles: HashMap::new() };
=======
let mut w = W { t: Vec::new(), next: d.handseed.max(0x100).saturating_add(0x1000), pstyles: HashMap::new(), reactors: hatch_reactors(d) };
>>>>>>> pr/871
// Pre-allocate handles for block records.
let mut paper_layouts: Vec<&Layout> = d.layouts.iter().collect();
paper_layouts.sort_by_key(|l| l.tab_order);
@@ -1939,7 +1992,13 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
w.i(70, 0);
let v = serde_json::to_value(s).unwrap_or_default();
let defaults = serde_json::to_value(DimStyle::default()).unwrap_or_default();
<<<<<<< HEAD
let mut newer = W { t: Vec::new(), next: 0, pstyles: HashMap::new(), layers: HashMap::new() };
||||||| 00c7613f
let mut newer = W { t: Vec::new(), next: 0, pstyles: HashMap::new() };
=======
let mut newer = W { t: Vec::new(), next: 0, pstyles: HashMap::new(), reactors: HashMap::new() };
>>>>>>> pr/871
for (field, code, kind) in dxf_ext::DIM_CODES {
let Some(enc) = v.get(*field).and_then(|x| cx.encode(*kind, x)) else { continue };
if !dxf_ext::NEWER_CODES.contains(code) {
+52
View File
@@ -103,3 +103,55 @@ fn sources_after_spline_fit_data_and_hostile_counts() {
assert_eq!(hatches(&back)[0].loops[0].sources, vec![Handle(0x1F)]);
assert!(hatches(&with(&poly_loop, "97\n-5\n"))[0].loops[0].sources.is_empty());
}
/// The groups of the first record of type `kind` with handle `h`.
fn record(p: &[(i32, String)], kind: &str, h: Handle) -> Vec<(i32, String)> {
let starts: Vec<usize> = p.iter().enumerate().filter(|(_, (c, _))| *c == 0).map(|(i, _)| i).collect();
starts
.iter()
.enumerate()
.map(|(n, &s)| p[s..starts.get(n + 1).copied().unwrap_or(p.len())].to_vec())
.find(|r| r.first().is_some_and(|(_, v)| v == kind) && r.get(1) == Some(&(5, h.hex())))
.unwrap_or_default()
}
/// An associative hatch inside a block keeps its boundary object, and the boundary carries the
/// hatch as a persistent reactor (as AutoCAD writes them), so a stretched boundary still drags
/// the fill along in AutoCAD (dynamic blocks).
#[test]
fn a_hatch_in_a_block_keeps_its_boundary_and_the_boundary_its_reactor() {
let mut d = Drawing::new_imperial();
let mut b = Block::new("Tag");
let outline = d.new_handle();
let fill = d.new_handle();
let k = EntityKind::LwPolyline(LwPolyline { vertices: square(0.0, 10.0), closed: true, const_width: 0.0, elevation: 0.0, plinegen: false });
b.entities.push(Entity::new(outline, k));
let loops = vec![HatchLoop { vertices: square(0.0, 10.0), outer: true, sources: vec![outline] }];
b.entities.push(Entity::new(fill, EntityKind::Hatch(hatch(loops, true))));
d.blocks.insert("Tag".into(), std::sync::Arc::new(b));
// A model-space hatch whose source is in the block is not linked to it.
let stray = vec![HatchLoop { vertices: square(0.0, 10.0), outer: true, sources: vec![outline] }];
let other = d.add(&Space::Model, Common::default(), EntityKind::Hatch(hatch(stray, true))).expect("add");
let p = pairs(&write_dxf(&d));
let poly = record(&p, "LWPOLYLINE", outline);
let reactors = ["{ACAD_REACTORS", &fill.hex(), "}"].map(String::from);
assert!(poly.windows(3).any(|w| w.iter().map(|(_, v)| v.clone()).eq(reactors.iter().cloned())), "{poly:?}");
assert!(!poly.iter().any(|(c, v)| *c == 330 && *v == other.hex()), "{poly:?}");
let h = record(&p, "HATCH", fill);
let at = h.iter().position(|(c, _)| *c == 97).expect("97");
assert_eq!(h[at..at + 2], [(97, "1".to_string()), (330, outline.hex())]);
assert!(record(&p, "HATCH", other).contains(&(97, "0".to_string())));
// Read back, from DXF and from DWG.
let back = read_dxf(join(&p).as_bytes()).expect("read");
let dwg = cadcraft_io::write(&d, "x.dwg").expect("dwg");
let from_dwg = cadcraft_io::read(&dwg, "x.dwg").expect("read dwg");
for (what, back) in [("dxf", &back), ("dwg", &from_dwg)] {
let blk = back.block("Tag").expect("block");
let sources: Vec<Vec<Handle>> = blk
.entities
.iter()
.filter_map(|e| if let EntityKind::Hatch(h) = &e.kind { Some(h.loops.iter().flat_map(|l| l.sources.clone()).collect()) } else { None })
.collect();
assert_eq!(sources, vec![vec![outline]], "{what}");
}
}
+2 -1
View File
@@ -76,7 +76,7 @@ early (#175), but the throughput problem remains. **DWG ≈ 30% read, ≈ 15% wr
| DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written |
| LEADER | yes | yes | |
| MULTILEADER | yes (#459) | yes, native MULTILEADER with MLEADERSTYLE (#459) | several landings per multileader are folded into one; per-object overrides and annotative scale contexts not kept |
| HATCH pattern / solid | yes | yes | |
| HATCH pattern / solid | yes | yes; associative hatches keep their boundary objects in blocks too, and the boundary carries the hatch reactor (stretched dynamic blocks drag the fill in AutoCAD) | |
| HATCH gradient | yes | yes (#162, fixes #80) | |
| SOLID, TRACE, 3DFACE | yes | yes | |
| ACAD_TABLE | yes | yes (entity + block) | |
@@ -136,6 +136,7 @@ our output in AutoCAD black-box.
|---|---|---|
| 2026-10-12 | minor | DXF/DWG save: the Model layout's plot settings are written as CADCraft plots model space, drawing extents scaled to fit (PLOTSETTINGS 74 = 1, 75 = 0), instead of plot type 5 "Layout information", which other programs' Plot dialogs can't use for model space |
| 2026-10-12 | minor | VIEWPORT frozen layers also written and read as AutoCAD's group 341 layer handles, so third-party viewers honour per-viewport freezing in DXF (DWG converter gap noted) |
| 2026-10-12 | minor | HATCH: associative hatches inside blocks keep their source objects; boundaries carry `{ACAD_REACTORS}` |
| 2026-10-11 | minor | Rows refreshed for merged work: code pages and `\M+` (#505), MULTILEADER (#459), IMAGE, MLINE/HELIX/REGION/TOLERANCE, VPORT/VIEW/UCS tables, modelled and raw objects, foreign data (#528), DWT templates and user `.lin`/`.pat`/`.pgp` files (#552), PDF output (#567, #571), plotter devices (#564), data extraction (#433, #534); weighted percentages not re-measured |
| 2026-10-11 | minor | DXF (ASCII and binary) and DWG saved at 2000–2018, 2018 by default (#421) |
| 2026-10-10 | minor | WIPEOUT and gradients written since #147/#162; other save losses remain (#201, #204, #208, #209, #213, #223) |