diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 9ca05162..970dbfe7 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -17,8 +17,15 @@ struct W { next: u64, /// Upper-case plot style name → its `ACDBPLACEHOLDER` handle (group 390 of layers and objects). pstyles: HashMap, +<<<<<<< HEAD /// Upper-case layer name → its LAYER table record handle (group 341 of viewports). layers: HashMap, +||||||| 00c7613f +======= + /// Persistent reactors of boundary objects: each source object of an associative hatch → + /// the hatches (groups `{ACAD_REACTORS 330 …}`), from [`hatch_reactors`]. + reactors: HashMap>, +>>>>>>> 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> { + let mut out: HashMap> = 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 = 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 = 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 = + 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 { 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 { 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) { diff --git a/crates/io/tests/hatch_assoc_roundtrip.rs b/crates/io/tests/hatch_assoc_roundtrip.rs index ec72b8e1..d7e58892 100644 --- a/crates/io/tests/hatch_assoc_roundtrip.rs +++ b/crates/io/tests/hatch_assoc_roundtrip.rs @@ -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 = 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> = 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}"); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d5a58a9b..74e850d3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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) |