From 47e978b58e8fdedbe6e14cd25c1692fb49b1391c Mon Sep 17 00:00:00 2001 From: ffishman Date: Sat, 24 Oct 2026 02:48:46 -0300 Subject: [PATCH] DXF/DWG: keep a file's own arrowhead blocks with standard names (_Dot, _DotSmall) (#1694) * DXF/DWG: keep a file's own arrowhead blocks with standard names Reading dropped every block with a standard arrowhead name (`_Dot`, `_DotSmall`, ...) that no INSERT used, since CADCraft regenerates its own on save. A block only a dimension style names (DIMBLK) was dropped too, so AutoCAD's `_Dot` (a polyline with width) or a user's redefinition came back as CADCraft's disc of 24 SOLIDs. Now only blocks whose content is exactly what CADCraft writes are dropped; any other content is kept and saved as it was, through DXF and DWG. PURGE keeps arrowhead blocks that dimension styles or dimension overrides name. Co-Authored-By: Claude Opus 5.5 (1M context) * Arrowheads: compare all properties, trim names in PURGE, test every standard arrow is_generated_arrow now requires the whole Common to match, so only blocks exactly as CADCraft writes them are regenerated. PURGE trims arrowhead names from dimension styles and overrides. A new test writes every standard arrowhead to DXF and DWG and checks none reads back as a user block. Review of #1694. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: ffishman <20211677+ffishman@users.noreply.github.com> --- crates/engine/src/cmd/blocks/purge.rs | 30 ++++++++++-- crates/engine/tests/purge_arrow_blocks.rs | 25 ++++++++++ crates/io/src/dxf_read.rs | 9 +++- crates/io/src/dxf_write.rs | 10 ++++ crates/io/src/tests.rs | 56 +++++++++++++++++++++++ docs/file-format-parity.md | 3 +- 6 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 crates/engine/tests/purge_arrow_blocks.rs diff --git a/crates/engine/src/cmd/blocks/purge.rs b/crates/engine/src/cmd/blocks/purge.rs index d7801bd55..5ec18b3c6 100644 --- a/crates/engine/src/cmd/blocks/purge.rs +++ b/crates/engine/src/cmd/blocks/purge.rs @@ -117,14 +117,35 @@ fn entities(d: &Drawing) -> impl Iterator impl Iterator + '_ { + dm.overrides + .iter() + .filter(|(k, _)| cadcraft_doc::DimStyle::field_name(k).is_some_and(|f| f.starts_with("arrowBlock") || f == "leaderArrowBlock")) + .filter_map(|(_, v)| v.as_str().map(|n| n.trim().to_string())) + .filter(|n| !n.is_empty()) +} + +/// Upper-case names of blocks reachable from model space, the layouts and dimensions, and the +/// arrowhead blocks dimension styles and dimension overrides name. fn used_blocks(d: &Drawing) -> HashSet { let mut used = HashSet::new(); let mut frontier: Vec = Vec::new(); + for st in &d.dim_styles { + frontier.extend( + [&st.arrow_block, &st.arrow_block1, &st.arrow_block2, &st.leader_arrow_block] + .into_iter() + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty()), + ); + } for e in d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())) { match &e.kind { EntityKind::Insert(i) => frontier.push(i.block.clone()), - EntityKind::Dimension(dm) => frontier.extend(dm.block.clone()), + EntityKind::Dimension(dm) => { + frontier.extend(dm.block.clone()); + frontier.extend(override_arrows(dm)); + } EntityKind::MLeader(m) => frontier.extend(m.block.as_ref().map(|b| b.block.clone())), _ => {} } @@ -139,7 +160,10 @@ fn used_blocks(d: &Drawing) -> HashSet { for e in b.entities.iter() { match &e.kind { EntityKind::Insert(i) => frontier.push(i.block.clone()), - EntityKind::Dimension(dm) => frontier.extend(dm.block.clone()), + EntityKind::Dimension(dm) => { + frontier.extend(dm.block.clone()); + frontier.extend(override_arrows(dm)); + } EntityKind::MLeader(m) => frontier.extend(m.block.as_ref().map(|b| b.block.clone())), _ => {} } diff --git a/crates/engine/tests/purge_arrow_blocks.rs b/crates/engine/tests/purge_arrow_blocks.rs new file mode 100644 index 000000000..b9e8899eb --- /dev/null +++ b/crates/engine/tests/purge_arrow_blocks.rs @@ -0,0 +1,25 @@ +//! PURGE keeps arrowhead blocks a dimension style or a dimension's overrides name (a file's own +//! `_Dot`, a user's arrow), and still removes blocks nothing references. + +use std::sync::Arc; + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Block, Circle, DimStyle, Entity, EntityKind, Handle}; +use cadcraft_engine::geom::Vec3; +use serde_json::json; + +#[test] +fn purge_keeps_arrowhead_blocks_dimension_styles_name() { + let mut s = Session::new(); + let d = s.doc_mut().unwrap(); + for (i, name) in ["_Dot", "MyArrow", "Unused"].into_iter().enumerate() { + let mut b = Block::new(name); + b.entities.push(Entity::new(Handle(0x9000 + i as u64), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 0.5 }))); + d.blocks.insert(name.into(), Arc::new(b)); + } + d.dim_styles.push(DimStyle { name: "Dots".into(), arrow_block1: " _DOT ".into(), leader_arrow_block: "MyArrow".into(), ..DimStyle::default() }); + let r = s.execute("purge", &json!({ "type": "blocks" })).unwrap(); + assert_eq!(r["purged"]["blocks"], json!(["Unused"]), "{r}"); + let d = s.doc().unwrap(); + assert!(d.block("_Dot").is_some() && d.block("MyArrow").is_some()); +} diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index bfd1ec0ad..48c032837 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -1816,8 +1816,13 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result { continue; } // Generated geometry we rebuild on save: table blocks, and standard arrowhead blocks - // nothing inserts. - if rx.table_blocks.contains(&up) || (crate::dxf_ext::is_standard_arrow(&name) && !rx.inserted.contains(&up)) { + // nothing inserts whose content is the one CADCraft writes. An arrowhead block with other + // content (AutoCAD's `_Dot`, a user's redefinition) is kept, so dimensions keep it. + let generated_arrow = || { + let own: Vec<&Entity> = ents.iter().map(|(_, _, e)| e).collect(); + crate::dxf_ext::is_standard_arrow(&name) && !rx.inserted.contains(&up) && crate::dxf_write::is_generated_arrow(&name, &own) + }; + if rx.table_blocks.contains(&up) || generated_arrow() { continue; } let mut b = Block::new(&name); diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 34bee75b2..03326b1ce 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -1514,6 +1514,16 @@ fn arrow_block_entities(kind: cadcraft_render::Arrowhead) -> Vec { out } +/// Whether the entities of the block `name` are the arrowhead block CADCraft writes for that +/// standard arrow name ([`arrow_block_entities`]): the same lines and solids with the same +/// properties (layer 0, ByBlock). Any other content (AutoCAD's own `_Dot`, a user's +/// redefinition, an older CADCraft's geometry) is the file's. +pub(crate) fn is_generated_arrow(name: &str, entities: &[&Entity]) -> bool { + let Some((_, kind)) = dxf_ext::arrow_block_name(name) else { return false }; + let ours = arrow_block_entities(kind); + ours.len() == entities.len() && ours.iter().zip(entities).all(|(a, b)| a.kind == b.kind && a.common == b.common) +} + /// Whether a DIMASSOC point reference to `k` names a point of it without naming a segment: /// true for objects made of one curve. Polylines are referenced by segment ([`polyline_vertex_ref`]); /// 3D polylines and multilines can't be yet, and references to them are left out, as AutoCAD diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 2c741b803..ffd524b99 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -876,6 +876,62 @@ fn user_block_arrowheads_are_referenced_not_regenerated() { assert_eq!(back.blocks.len(), d.blocks.len()); } +/// Every standard arrowhead CADCraft generates reads back as its own geometry, through DXF and +/// DWG, so reopening a drawing CADCraft wrote never turns them into user blocks. +#[test] +fn generated_arrowhead_blocks_are_not_kept_as_user_blocks() { + let mut d = Drawing::new_metric(); + for (i, (_, name)) in crate::dxf_ext::ARROWS.iter().enumerate() { + d.dim_styles.push(DimStyle { name: format!("S{i}"), arrow_block: (*name).into(), ..DimStyle::default() }); + } + let dxf = write_dxf(&d); + for (_, name) in crate::dxf_ext::ARROWS.iter().filter(|(k, _)| *k != cadcraft_render::Arrowhead::ClosedFilled) { + assert!(dxf.contains(&format!("\r\n{name}\r\n")), "{name} is written"); + } + let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap(); + let from_dwg = read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap(); + for (back, what) in [(read_dxf(dxf.as_bytes()).unwrap(), "DXF"), (from_dwg, "DWG")] { + assert!(back.blocks.keys().all(|k| !k.starts_with('_')), "{what}: {:?}", back.blocks.keys().collect::>()); + assert!(roundtrip(&back).blocks.keys().all(|k| !k.starts_with('_')), "{what}: second save"); + } +} + +/// A file's own arrowhead block with a standard name (AutoCAD's `_Dot` is a closed polyline +/// with width, not CADCraft's disc of solids) is kept when only a dimension style names it, and +/// saved as it was, through DXF and DWG, instead of being replaced by CADCraft's geometry. +#[test] +fn file_arrowhead_blocks_with_standard_names_are_kept() { + let mut d = Drawing::new_metric(); + let donut = LwPolyline { + vertices: vec![ + PolyVertex { p: Vec2::new(-0.25, 0.0), bulge: 1.0, start_width: 0.5, end_width: 0.5 }, + PolyVertex { p: Vec2::new(0.25, 0.0), bulge: 1.0, start_width: 0.5, end_width: 0.5 }, + ], + closed: true, + const_width: 0.0, + elevation: 0.0, + plinegen: false, + }; + for (name, kind) in [("_Dot", EntityKind::LwPolyline(donut)), ("_DotSmall", EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 0.0625 }))] { + let mut b = Block::new(name); + b.entities.push(Entity::new(Handle(0), kind)); + d.blocks.insert(name.into(), std::sync::Arc::new(b)); + } + d.dim_styles.push(DimStyle { name: "Dots".into(), arrow_block1: "_Dot".into(), arrow_block2: "_DotSmall".into(), ..DimStyle::default() }); + let kinds = |d: &Drawing, name: &str| d.block(name).map(|b| b.entities.iter().map(|e| e.kind.clone()).collect::>()).unwrap_or_default(); + let dxf = write_dxf(&d); + let dwg = cadcraft_dwg::dxf_to_dwg(dxf.as_bytes()).unwrap(); + let from_dwg = read_dxf(&cadcraft_dwg::dwg_to_dxf(&dwg).unwrap()).unwrap(); + for (back, what) in [(read_dxf(dxf.as_bytes()).unwrap(), "DXF"), (from_dwg, "DWG")] { + for name in ["_Dot", "_DotSmall"] { + assert_eq!(kinds(&back, name), kinds(&d, name), "{what}: {name}"); + } + let again = write_dxf(&back); + assert!(!again.contains("\nSOLID\r"), "{what}: no arrowhead geometry of CADCraft's is written"); + assert_eq!(kinds(&roundtrip(&back), "_Dot"), kinds(&d, "_Dot"), "{what}: a second save keeps it too"); + } +} + #[test] fn dimension_overrides_roundtrip_as_dstyle_xdata() { let mut d = Drawing::new_imperial(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c598193d7..354a95898 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (DWG read leaves out orphaned DGN line style components) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (a file's own standard-named arrowhead blocks are kept) · **Target:** Autodesk AutoCAD 2027 Every format AutoCAD 2027 reads or writes, with CADCraft's support, fidelity and tests. Work items are in [gaps.md](gaps.md#file-formats); the weighted score feeds @@ -138,6 +138,7 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | A file's own arrowhead blocks with standard names (AutoCAD's `_Dot`, `_DotSmall`), named only by a dimension style, are kept and saved as they were instead of being replaced by CADCraft's solids; PURGE keeps arrowhead blocks dimension styles name | | 2026-10-17 | minor | DWG read: DGN line style components no line style definition reaches are left out before the DXF bridge, with a read note; an 83 MB site plan with 888,483 of them (1.2 GB of DXF, over the limit) now opens (#1482) | | 2026-10-12 | minor | PDF output: object transparency plotted as PDF alpha (`/ExtGState` `/ca` `/CA`) when the page setup's or the plot's "Plot transparency" is on; PNG plots blend it, SVG uses `opacity`; plots without it are unchanged byte for byte | | 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 |