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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: ffishman <20211677+ffishman@users.noreply.github.com>
This commit is contained in:
ffishman
2026-10-24 01:48:46 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 ffishman
parent a68d0a458b
commit 47e978b58e
6 changed files with 127 additions and 6 deletions
+27 -3
View File
@@ -117,14 +117,35 @@ fn entities(d: &Drawing) -> impl Iterator<Item = &std::sync::Arc<cadcraft_doc::E
d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())).chain(d.blocks.values().flat_map(|b| b.entities.iter())).take(5_000_000)
}
/// Upper-case names of blocks reachable from model space, the layouts and dimensions.
/// The arrowhead block names a dimension's overrides set.
fn override_arrows(dm: &cadcraft_doc::Dimension) -> impl Iterator<Item = String> + '_ {
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<String> {
let mut used = HashSet::new();
let mut frontier: Vec<String> = 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<String> {
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())),
_ => {}
}
+25
View File
@@ -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());
}
+7 -2
View File
@@ -1816,8 +1816,13 @@ fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {
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);
+10
View File
@@ -1514,6 +1514,16 @@ fn arrow_block_entities(kind: cadcraft_render::Arrowhead) -> Vec<Entity> {
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
+56
View File
@@ -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::<Vec<_>>());
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::<Vec<_>>()).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();
+2 -1
View File
@@ -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 |