diff --git a/crates/engine/tests/dimassoc_saved.rs b/crates/engine/tests/dimassoc_saved.rs new file mode 100644 index 00000000..c561663a --- /dev/null +++ b/crates/engine/tests/dimassoc_saved.rs @@ -0,0 +1,140 @@ +//! #1651: the issue's repro through the commands. Every dimension CADCraft reports as +//! associated (the diameter, radius and angular ones, as well as the linear control) is saved +//! with a DIMASSOC object whose references name its geometry. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Handle}; +use serde_json::{Value, json}; + +/// The entity handle a command result reports, from its `handle` field. +fn handle(r: &Value) -> Handle { + Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap() +} + +/// The first line of a LINE result (`handles`). +fn first_line(r: &Value) -> Handle { + Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap() +} + +/// The DXF tag pairs, `(code, value)`. +fn pairs(text: &str) -> Vec<(i32, String)> { + let lines: Vec<&str> = text.lines().collect(); + let mut out = Vec::new(); + for chunk in lines.chunks(2) { + let [code, value] = chunk else { break }; + let Ok(c) = code.trim().parse::() else { continue }; + out.push((c, value.trim().to_string())); + } + out +} + +/// The DIMASSOC object of a dimension, if the file has one. +fn dimassoc(text: &str, dim: Handle) -> Option> { + let hex = dim.hex(); + let mut recs = Vec::new(); + let mut cur: Option> = None; + for (c, v) in pairs(text) { + if c == 0 { + if let Some(r) = cur.take() { + recs.push(r); + } + cur = Some(vec![(c, v)]); + } else if let Some(r) = cur.as_mut() { + r.push((c, v)); + } + } + if let Some(r) = cur { + recs.push(r); + } + recs.into_iter() + .filter(|r| r.first().is_some_and(|(c, v)| *c == 0 && v == "DIMASSOC")) + .find(|r| r.iter().rfind(|(c, _)| *c == 330).is_some_and(|(_, v)| v.eq_ignore_ascii_case(&hex))) +} + +/// The `AcDbOsnapPointRef` blocks of a DIMASSOC record, in file order: one per reference the +/// dimension makes to its geometry. +fn point_refs(rec: &[(i32, String)]) -> Vec> { + let mut out = Vec::new(); + let mut cur: Option> = None; + for (c, v) in rec { + if *c == 1 && v == "AcDbOsnapPointRef" { + if let Some(r) = cur.take() { + out.push(r); + } + cur = Some(Vec::new()); + } else if let Some(r) = cur.as_mut() { + r.push((*c, v.clone())); + } + } + if let Some(r) = cur { + out.push(r); + } + out +} + +/// The first integer value of a group code in a record (72 the osnap type, 90 the flags). +fn int(rec: &[(i32, String)], code: i32) -> Option { + rec.iter().find(|(c, _)| *c == code).and_then(|(_, v)| v.parse().ok()) +} + +/// The first string value of a group code in a record (331 the referenced entity, 332 the other +/// entity of an intersection). +fn string(rec: &[(i32, String)], code: i32) -> Option<&str> { + rec.iter().find(|(c, _)| *c == code).map(|(_, v)| v.as_str()) +} + +/// #1651's repro through the commands: after DIMREASSOCIATE, every dimension the session calls +/// associated (diameter, radius, angular and a linear control) is written with a DIMASSOC whose +/// references name real geometry. Guards the saving side, where radial and diametric dimensions +/// used to fall through the kind-to-slot match and be written without any DIMASSOC object. +#[test] +fn the_issue_repro_saves_every_associated_dimension_with_dimassoc() { + let mut s = Session::new(); + s.execute("dimstyle", &json!({ "name": "D", "current": true, "DIMTXT": 5, "DIMASZ": 5, "DIMDEC": 0 })).unwrap(); + let circle = handle(&s.execute("circle", &json!({ "center": [0, 0], "radius": 30 })).unwrap()); + let diameter = handle(&s.execute("dimdiameter", &json!({ "handle": circle.hex(), "at": [40, 40] })).unwrap()); + let radius = handle(&s.execute("dimradius", &json!({ "handle": circle.hex(), "at": [-40, -40] })).unwrap()); + let l1 = first_line(&s.execute("line", &json!({ "points": [[100, 0], [200, 0]] })).unwrap()); + let l2 = first_line(&s.execute("line", &json!({ "points": [[100, 0], [180, 60]] })).unwrap()); + let angular = handle(&s.execute("dimangular", &json!({ "lines": [l1.hex(), l2.hex()], "at": [160, 20] })).unwrap()); + let l3 = first_line(&s.execute("line", &json!({ "points": [[0, -80], [100, -80]] })).unwrap()); + let linear = handle(&s.execute("dimlinear", &json!({ "object": l3.hex(), "at": [50, -100] })).unwrap()); + + let dims = [diameter, radius, angular, linear]; + { + let d = s.doc().unwrap(); + for h in dims { + match d.entity(h).map(|e| &e.kind) { + Some(EntityKind::Dimension(dm)) => assert!(!dm.assoc.is_empty(), "{} is associative in CADCraft", h.hex()), + _ => panic!("{} is not a dimension", h.hex()), + } + } + } + // The issue runs DIMREASSOCIATE before saving; it does not lose any of them. + let hs: Vec = dims.iter().map(|h| h.hex()).collect(); + let r = s.execute("dimreassociate", &json!({ "handles": hs })).unwrap(); + assert_eq!(r["associated"], json!(4), "{r}"); + + let text = cadcraft_io::write_dxf(s.doc().unwrap()); + for (h, what) in [(diameter, "diameter"), (radius, "radius"), (angular, "angular"), (linear, "linear")] { + let rec = dimassoc(&text, h).unwrap_or_else(|| panic!("{what} dimension {} has a DIMASSOC", h.hex())); + let refs = point_refs(&rec); + assert!(!refs.is_empty(), "{what}: the DIMASSOC has point references"); + assert!(refs.iter().all(|r| string(r, 331).is_some()), "{what}: every reference names geometry"); + } + // The radial and diametric dimensions name the circle by its centre. + for (h, what) in [(diameter, "diameter"), (radius, "radius")] { + let refs = point_refs(&dimassoc(&text, h).unwrap()); + assert_eq!(refs.len(), 1, "{what}: one reference, as AutoCAD writes radial dimensions"); + assert_eq!(int(&refs[0], 72), Some(3), "{what}: a centre snap"); + assert!(string(&refs[0], 331).is_some_and(|h| h.eq_ignore_ascii_case(&circle.hex())), "{what}: to the circle"); + } + // The angular dimension names both lines, and the linear one its line. + let refs = point_refs(&dimassoc(&text, angular).unwrap()); + assert!(refs.len() >= 2, "angular: at least both leg points: {refs:?}"); + let named = |r: &[(i32, String)], h: Handle| string(r, 331).is_some_and(|x| x.eq_ignore_ascii_case(&h.hex())); + assert!(refs.iter().any(|r| named(r, l1)) && refs.iter().any(|r| named(r, l2)), "angular: both lines"); + let refs = point_refs(&dimassoc(&text, linear).unwrap()); + assert_eq!(refs.len(), 2, "linear: both origins"); + assert!(refs.iter().all(|r| named(r, l3)), "linear: its line"); +} diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 70642efa..5860b1e0 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -2153,7 +2153,12 @@ impl Objects { } } -/// Associativity from a DIMASSOC object (files from other writers). +/// Associativity from a DIMASSOC object (files from other writers). The dimension kind decides +/// which definition points the references name, in the order AutoCAD writes them: `p13`/`p14` +/// for linear, aligned and arc-length dimensions, both ends of both lines (`p13`..`p16`) for +/// angular dimensions, the three points of an angular-3-point dimension, `p13` for ordinate +/// dimensions, and for radial or diametric dimensions the single circle reference +/// ([`radial_links`]). CADCraft's own xdata links, when present, win. fn dimassoc(d: &mut Drawing, tags: &[Tag]) { let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbDimAssoc").unwrap_or(0); let tail = tags.get(start..).unwrap_or(&[]); @@ -2162,10 +2167,25 @@ fn dimassoc(d: &mut Drawing, tags: &[Tag]) { if !dm.assoc.is_empty() { return; } + // A radial or diametric dimension names its circle once, by its centre. + if matches!(dm.kind, DimKind::Radius | DimKind::Diameter) { + let links = radial_links(d, dm, tail); + if !links.is_empty() { + let _ = d.modify_entity(dim_h, |e| { + if let EntityKind::Dimension(dm) = &mut e.kind { + dm.assoc = links; + } + }); + } + return; + } let slots: &[&str] = match dm.kind { DimKind::Linear { .. } | DimKind::Aligned => &["p13", "p14"], + DimKind::Angular => &["p13", "p14", "p15", "p16"], + DimKind::Angular3P => &["p13", "p14", "p15"], + DimKind::ArcLength => &["p13", "p14"], DimKind::Ordinate { .. } => &["p13"], - _ => return, + DimKind::Radius | DimKind::Diameter => return, }; let flags = T(tail).i(90).unwrap_or(0); let used: Vec<&str> = slots.iter().enumerate().filter(|(i, _)| flags & (1 << i) != 0).map(|(_, s)| *s).collect(); @@ -2194,9 +2214,51 @@ fn dimassoc(d: &mut Drawing, tags: &[Tag]) { } } +/// Associations of a radial or diametric dimension from its one standard reference: the circle +/// or arc it measures, by its centre (osnap 3). The definition points are then on that circle, +/// so the dimension follows its radius or diameter. +fn radial_links(d: &Drawing, dm: &Dimension, tail: &[Tag]) -> Vec { + let starts: Vec = tail.iter().enumerate().filter(|(_, x)| x.code == 1 && x.str() == "AcDbOsnapPointRef").map(|(i, _)| i).take(8).collect(); + for (k, s) in starts.iter().enumerate() { + let end = starts.get(k + 1).copied().unwrap_or(tail.len()); + let r = T(tail.get(s + 1..end).unwrap_or(&[])); + if !matches!(r.i(72), Some(3 | 4)) { + continue; + } + let Some(handle) = r.s(331).and_then(|h| Handle::parse_hex(&h)) else { continue }; + let Some(centre) = circle_centre(d, handle) else { continue }; + let on = |point: &str, p: Vec3| { + let angle = centre.angle_to(p.xy()); + angle.is_finite().then(|| DimAssoc { point: point.into(), handle, snap: AssocSnap::OnCircle { angle } }) + }; + let links = match dm.kind { + DimKind::Radius => on("p15", dm.p15).map(|p15| vec![DimAssoc { point: "defpt".into(), handle, snap: AssocSnap::Center }, p15]), + DimKind::Diameter => [on("defpt", dm.defpt), on("p15", dm.p15)].into_iter().collect::>>(), + _ => None, + }; + if let Some(links) = links { + return links; + } + } + Vec::new() +} + +/// The planar centre of a circle or arc, or `None` for any other entity: the point a radial or +/// diametric dimension's single DIMASSOC reference names. +fn circle_centre(d: &Drawing, h: Handle) -> Option { + match &d.entity(h)?.kind { + EntityKind::Circle(c) => Some(c.center.xy()), + EntityKind::Arc(a) => Some(a.center.xy()), + _ => None, + } +} + /// A CADCraft snap from a DIMASSOC object snap type and point, and for a polyline the segment /// the point is an end of: AutoCAD writes no usable point there (10/20 are 0, #1049), the segment -/// and the osnap (1 its end, 13 its start) name the vertex. +/// and the osnap (1 its end, 13 its start) name the vertex. For a line or arc the osnap alone +/// says which end: 13 is the start and 1 the end, whatever the stored point (the point is +/// 0,0,0 for curves, and choosing by distance gave the wrong end for arcs). A point entity has +/// only one position, so both codes mean it. fn infer_snap(d: &Drawing, osnap: i64, main: Handle, other: Option, at: Vec2, segment: Option) -> Option { let e = d.entity(main)?; if let (EntityKind::LwPolyline(p), Some(k), 1 | 13) = (&e.kind, segment, osnap) { @@ -2208,16 +2270,21 @@ fn infer_snap(d: &Drawing, osnap: i64, main: Handle, other: Option, at: } Some(match osnap { 1 | 13 => match &e.kind { + // AutoCAD names a curve's start with 13 and its end with 1 (the stored point is 0,0,0). + // Other writers use 1 for either end of a line with the point stored: take the nearer. EntityKind::Line(l) => { - if osnap == 13 || l.a.xy().dist(at) <= l.b.xy().dist(at) { + if osnap == 13 || (at != Vec2::ZERO && l.a.xy().dist(at) <= l.b.xy().dist(at)) { AssocSnap::Start } else { AssocSnap::End } } - EntityKind::Arc(a) => { - let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end); - if osnap == 13 || g.start_point().dist(at) <= g.end_point().dist(at) { AssocSnap::Start } else { AssocSnap::End } + EntityKind::Arc(_) => { + if osnap == 13 { + AssocSnap::Start + } else { + AssocSnap::End + } } EntityKind::LwPolyline(p) => { let index = p.vertices.iter().enumerate().min_by(|x, y| x.1.p.dist(at).total_cmp(&y.1.p.dist(at))).map(|(i, _)| i)?; diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 6af79908..f21a28d8 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -1559,20 +1559,34 @@ pub(crate) fn polyline_vertex_ref(p: &LwPolyline, index: usize) -> Option<(i64, } /// Standard DIMASSOC point references of an associative dimension: the extension-line -/// origins of linear/aligned dimensions and the feature point of ordinate dimensions, linked +/// origins of linear/aligned dimensions, both ends of both lines of an angular dimension, the +/// feature point of ordinate dimensions and the circle of a radial/diametric dimension, linked /// to objects that exist ([`whole_object_ref`]). CADCraft's exact links travel in xdata as well. fn std_assoc_refs(d: &Drawing, dm: &Dimension) -> (i64, Vec) { + if matches!(dm.kind, DimKind::Radius | DimKind::Diameter) { + return radial_ref(d, &dm.assoc); + } let slots: &[&str] = match dm.kind { DimKind::Linear { .. } | DimKind::Aligned => &["p13", "p14"], + DimKind::Angular => &["p13", "p14", "p15", "p16"], + DimKind::Angular3P => &["p13", "p14", "p15"], + // Saved as an aligned dimension (`dim_xdata` marks it): its extension origins are the arc ends. + DimKind::ArcLength => &["p13", "p14"], DimKind::Ordinate { .. } => &["p13"], - _ => &[], + DimKind::Radius | DimKind::Diameter => &[], }; let mut flags = 0; let mut refs = Vec::new(); for (slot, name) in slots.iter().enumerate() { let Some(a) = dm.assoc.iter().find(|a| a.point == *name) else { continue }; let Some(ent) = d.entity(a.handle) else { continue }; - let point = if *name == "p13" { dm.p13 } else { dm.p14 }; + let point = match *name { + "p13" => dm.p13, + "p14" => dm.p14, + "p15" => dm.p15, + "p16" => dm.p16, + _ => continue, + }; if let EntityKind::LwPolyline(p) = &ent.kind { let index = match &a.snap { AssocSnap::Vertex { index } => Some(*index), @@ -1589,7 +1603,10 @@ fn std_assoc_refs(d: &Drawing, dm: &Dimension) -> (i64, Vec) { continue; } let (osnap, param, other) = match &a.snap { - AssocSnap::Start | AssocSnap::End | AssocSnap::Vertex { .. } => (1, 0.0, None), + // AutoCAD marks a curve's start with osnap 13 and its end with osnap 1 (group 72). + AssocSnap::Start => (if matches!(ent.kind, EntityKind::Point(_)) { 1 } else { 13 }, 0.0, None), + AssocSnap::End => (1, 1.0, None), + AssocSnap::Vertex { .. } => (1, 0.0, None), AssocSnap::Mid => (2, 0.0, None), AssocSnap::Center => (if matches!(ent.kind, EntityKind::Point(_)) { 4 } else { 3 }, 0.0, None), AssocSnap::OnCircle { angle } => (10, *angle, None), @@ -1606,6 +1623,27 @@ fn std_assoc_refs(d: &Drawing, dm: &Dimension) -> (i64, Vec) { (flags, refs) } +/// The single standard reference of a radial or diametric dimension: the circle or arc it +/// measures, by its centre, with associativity flag 2. AutoCAD writes radial and diametric +/// dimensions that way: the one reference names the geometry, whose radius or diameter the +/// dimension then follows. Returns no flags and no references when the association does not name +/// a circle or arc, so such a dimension is saved unassociated rather than pointing at nothing. +fn radial_ref(d: &Drawing, assoc: &[DimAssoc]) -> (i64, Vec) { + for a in assoc { + let Some(ent) = d.entity(a.handle) else { continue }; + let centre = match &ent.kind { + EntityKind::Circle(c) => c.center.xy(), + EntityKind::Arc(arc) => arc.center.xy(), + _ => continue, + }; + if !centre.is_finite() { + continue; + } + return (2, vec![OsnapRef { osnap: 3, main: a.handle, segment: 0, param: 0.0, point: centre.to3(0.0), other: None }]); + } + (0, Vec::new()) +} + /// Layouts with viewports but without the paper-space viewport (id 1, the sheet itself), keyed /// by their first viewport. AutoCAD and DWG take a layout's first viewport as its paper-space /// viewport (DWG does not store viewport ids; readers number them in order), so a layout made in diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 608162ef..1a2f0f86 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -1073,6 +1073,11 @@ fn damaged_dwg_opens_partially_and_says_what_it_left_out() { assert!(partial > 0, "no damaged copy read partially"); } +/// A dimension's standard DIMASSOC objects carry the same associations as CADCraft's xdata, so +/// a file from another writer (xdata stripped) still follows its geometry: a linear dimension's +/// two origins, a polyline's vertex named by its segment (#1049), and since #1651 a radial +/// dimension's circle, whose centre comes back as a `Center` link and its rim point as +/// `OnCircle`. #[test] fn dimension_associativity_roundtrips() { let mut d = Drawing::new_imperial(); @@ -1144,7 +1149,10 @@ fn dimension_associativity_roundtrips() { assert_eq!(fa.len(), 2); assert_eq!(fa[1], ali.assoc[1], "the polyline vertex, named by its segment (#1049)"); assert!(matches!(fa[0].snap, AssocSnap::OnCircle { angle } if angle.abs() < 1e-9)); - assert!(assoc_of(&foreign, hr).is_empty(), "radial links are CADCraft-only"); + let fr = assoc_of(&foreign, hr); + assert_eq!(fr.len(), 2, "a radial dimension's circle comes from the standard DIMASSOC"); + assert_eq!(fr[0], rad.assoc[0], "the centre"); + assert!(matches!(fr[1].snap, AssocSnap::OnCircle { angle } if angle.is_finite())); } /// #1049: a polyline vertex is referenced as AutoCAD does: an end of one of its segments, diff --git a/crates/io/tests/dimassoc_angular_radial.rs b/crates/io/tests/dimassoc_angular_radial.rs new file mode 100644 index 00000000..ec1a11f6 --- /dev/null +++ b/crates/io/tests/dimassoc_angular_radial.rs @@ -0,0 +1,290 @@ +//! #1651: angular, diameter and radius dimensions are saved with a DIMASSOC object, as linear +//! and ordinate dimensions already are, so AutoCAD follows their geometry. The standard objects +//! alone (CADCraft xdata stripped) must associate the same points, in DXF and in DWG. + +use cadcraft_doc::{AssocSnap, Circle, Common, DimAssoc, DimKind, Dimension, Drawing, EntityKind, Handle, Line, Space}; +use cadcraft_geom::Vec3; +use cadcraft_io::{Version, read, read_dxf, write_dxf}; + +/// A dimension entity of the given kind with all its definition points and associations, for +/// adding straight to a drawing; the style and text are left at defaults since only the +/// geometry and associations are under test. +fn dim(kind: DimKind, defpt: Vec3, p13: Vec3, p14: Vec3, p15: Vec3, p16: Vec3, assoc: Vec) -> EntityKind { + EntityKind::Dimension(Dimension { + kind, + defpt, + text_mid: Vec3::ZERO, + p13, + p14, + p15, + p16, + text: String::new(), + style: "Standard".into(), + measurement: 0.0, + text_rotation: 0.0, + user_text_pos: false, + block: None, + overrides: Default::default(), + assoc, + marks: Default::default(), + }) +} + +/// The drawing of the issue's repro: a circle with a diameter and a radius dimension, two lines +/// with an angular dimension between them, and a line with a linear dimension. The associations +/// are the ones the commands make (`assoc::radial_assoc` and `assoc::reassociate`). +struct Case { + d: Drawing, + circle: Handle, + l1: Handle, + l2: Handle, + hd: Handle, + hr: Handle, + ha: Handle, + hl: Handle, +} + +fn case() -> Case { + let mut d = Drawing::new_metric(); + let c = Common::default; + let circle = d.add(&Space::Model, c(), EntityKind::Circle(Circle { center: Vec3::ZERO, radius: 30.0 })).unwrap(); + let l1 = d.add(&Space::Model, c(), EntityKind::Line(Line { a: Vec3::new(100.0, 0.0, 0.0), b: Vec3::new(200.0, 0.0, 0.0) })).unwrap(); + let l2 = d.add(&Space::Model, c(), EntityKind::Line(Line { a: Vec3::new(100.0, 0.0, 0.0), b: Vec3::new(180.0, 60.0, 0.0) })).unwrap(); + let l3 = d.add(&Space::Model, c(), EntityKind::Line(Line { a: Vec3::new(0.0, -80.0, 0.0), b: Vec3::new(100.0, -80.0, 0.0) })).unwrap(); + + let on = |point: &str, angle: f64| DimAssoc { point: point.into(), handle: circle, snap: AssocSnap::OnCircle { angle } }; + let diameter = dim( + DimKind::Diameter, + Vec3::new(30.0, 0.0, 0.0), + Vec3::ZERO, + Vec3::ZERO, + Vec3::new(-30.0, 0.0, 0.0), + Vec3::ZERO, + vec![on("defpt", 0.0), on("p15", std::f64::consts::PI)], + ); + let side = 30.0 / std::f64::consts::SQRT_2; + let radius = dim( + DimKind::Radius, + Vec3::ZERO, + Vec3::ZERO, + Vec3::ZERO, + Vec3::new(-side, -side, 0.0), + Vec3::ZERO, + vec![DimAssoc { point: "defpt".into(), handle: circle, snap: AssocSnap::Center }, on("p15", 225f64.to_radians())], + ); + let angular = dim( + DimKind::Angular3P, + Vec3::new(160.0, 20.0, 0.0), + Vec3::new(200.0, 0.0, 0.0), + Vec3::new(180.0, 60.0, 0.0), + Vec3::new(100.0, 0.0, 0.0), + Vec3::ZERO, + vec![ + DimAssoc { point: "p13".into(), handle: l1, snap: AssocSnap::End }, + DimAssoc { point: "p14".into(), handle: l2, snap: AssocSnap::End }, + DimAssoc { point: "p15".into(), handle: l1, snap: AssocSnap::Intersection { other: l2 } }, + ], + ); + let linear = dim( + DimKind::Linear { rotation: 0.0 }, + Vec3::new(50.0, -100.0, 0.0), + Vec3::new(0.0, -80.0, 0.0), + Vec3::new(100.0, -80.0, 0.0), + Vec3::ZERO, + Vec3::ZERO, + vec![ + DimAssoc { point: "p13".into(), handle: l3, snap: AssocSnap::Start }, + DimAssoc { point: "p14".into(), handle: l3, snap: AssocSnap::End }, + ], + ); + let hd = d.add(&Space::Model, Common::default(), diameter).unwrap(); + let hr = d.add(&Space::Model, Common::default(), radius).unwrap(); + let ha = d.add(&Space::Model, Common::default(), angular).unwrap(); + let hl = d.add(&Space::Model, Common::default(), linear).unwrap(); + Case { d, circle, l1, l2, hd, hr, ha, hl } +} + +/// The DXF tag pairs, `(code, value)`. +fn pairs(text: &str) -> Vec<(i32, String)> { + let lines: Vec<&str> = text.lines().collect(); + let mut out = Vec::new(); + for chunk in lines.chunks(2) { + let [code, value] = chunk else { break }; + let Ok(c) = code.trim().parse::() else { continue }; + out.push((c, value.trim().to_string())); + } + out +} + +/// The records of a DXF (0-group name and the groups that follow it). +fn records(text: &str) -> Vec> { + let mut out = Vec::new(); + let mut cur: Option> = None; + for (c, v) in pairs(text) { + if c == 0 { + if let Some(r) = cur.take() { + out.push(r); + } + cur = Some(vec![(c, v)]); + } else if let Some(r) = cur.as_mut() { + r.push((c, v)); + } + } + if let Some(r) = cur { + out.push(r); + } + out +} + +/// The DIMASSOC object of a dimension, if the file has one. +fn dimassoc(text: &str, dim: Handle) -> Option> { + let hex = dim.hex(); + records(text) + .into_iter() + .filter(|r| r.first().is_some_and(|(c, v)| *c == 0 && v == "DIMASSOC")) + .find(|r| r.iter().rfind(|(c, _)| *c == 330).is_some_and(|(_, v)| v.eq_ignore_ascii_case(&hex))) +} + +/// The AcDbOsnapPointRef blocks of a DIMASSOC record. +fn point_refs(rec: &[(i32, String)]) -> Vec> { + let mut out = Vec::new(); + let mut cur: Option> = None; + for (c, v) in rec { + if *c == 1 && v == "AcDbOsnapPointRef" { + if let Some(r) = cur.take() { + out.push(r); + } + cur = Some(Vec::new()); + } else if let Some(r) = cur.as_mut() { + r.push((*c, v.clone())); + } + } + if let Some(r) = cur { + out.push(r); + } + out +} + +/// The first integer value of a group code in a record (72 the osnap type, 90 the flags). +fn int(rec: &[(i32, String)], code: i32) -> Option { + rec.iter().find(|(c, _)| *c == code).and_then(|(_, v)| v.parse().ok()) +} + +/// The first string value of a group code in a record (331 the referenced entity, 332 the other +/// entity of an intersection). +fn string(rec: &[(i32, String)], code: i32) -> Option<&str> { + rec.iter().find(|(c, _)| *c == code).map(|(_, v)| v.as_str()) +} + +/// A DXF without CADCraft's xdata, as a file from another writer (whose DIMASSOC objects must +/// stand on their own). +fn without_cadcraft_xdata(text: &str) -> String { + let mut out = String::new(); + let mut skipping = false; + for (c, v) in pairs(text) { + if c == 1001 { + skipping = v == "CADCRAFT"; + } else if c < 1000 { + skipping = false; + } + if !skipping { + out.push_str(&format!("{c}\n{v}\n")); + } + } + out +} + +/// The associations a read-back dimension carries, or none if the handle is not a dimension. +fn assoc_of(d: &Drawing, h: Handle) -> Vec { + match d.entity(h).map(|e| &e.kind) { + Some(EntityKind::Dimension(dm)) => dm.assoc.clone(), + _ => Vec::new(), + } +} + +/// Whether two angles name the same direction, allowing for a whole turn and rounding. +fn same_angle(a: f64, b: f64) -> bool { + let turn = std::f64::consts::TAU; + let d = (a - b).rem_euclid(turn); + d.min(turn - d) < 1e-6 +} + +/// The associations the standard objects give, without CADCraft's xdata. +fn assert_standard_assoc(c: &Case, d: &Drawing, what: &str) { + let dia = assoc_of(d, c.hd); + assert_eq!(dia.len(), 2, "{what}: diameter defpt and p15"); + assert!(dia.iter().all(|a| a.handle == c.circle), "{what}: diameter follows the circle: {dia:?}"); + assert!(matches!(dia[0].snap, AssocSnap::OnCircle { angle } if same_angle(angle, 0.0)), "{what}: {:?}", dia[0]); + assert!(matches!(dia[1].snap, AssocSnap::OnCircle { angle } if same_angle(angle, std::f64::consts::PI)), "{what}: {:?}", dia[1]); + + let rad = assoc_of(d, c.hr); + assert_eq!(rad.len(), 2, "{what}: radius defpt and p15"); + assert_eq!(rad[0].handle, c.circle, "{what}: radius centre: {rad:?}"); + assert!(matches!(rad[0].snap, AssocSnap::Center), "{what}: {:?}", rad[0]); + assert!(matches!(rad[1].snap, AssocSnap::OnCircle { angle } if same_angle(angle, 225f64.to_radians())), "{what}: {:?}", rad[1]); + + let ang = assoc_of(d, c.ha); + assert_eq!(ang.len(), 3, "{what}: angular leg points and vertex"); + assert_eq!((ang[0].handle, &ang[0].snap), (c.l1, &AssocSnap::End), "{what}: {ang:?}"); + assert_eq!((ang[1].handle, &ang[1].snap), (c.l2, &AssocSnap::End), "{what}: {ang:?}"); + assert_eq!(ang[2].handle, c.l1, "{what}: the vertex is the lines' intersection: {ang:?}"); + assert!(matches!(ang[2].snap, AssocSnap::Intersection { other } if other == c.l2), "{what}: {ang:?}"); + + let lin = assoc_of(d, c.hl); + assert_eq!(lin.len(), 2, "{what}: linear is unchanged"); + assert_eq!((lin[0].handle, &lin[0].snap), (lin[1].handle, &AssocSnap::Start), "{what}: {lin:?}"); + assert!(matches!(lin[1].snap, AssocSnap::End), "{what}: {lin:?}"); +} + +/// The writer half of #1651: the DXF of the repro's drawing has one DIMASSOC per dimension, with +/// the references AutoCAD writes for each kind -- one centre reference (flag 2) for radius and +/// diameter, three for an angular dimension (both leg ends and the lines' intersection), and the +/// two origins of the linear control. Guards the kind-to-slot table and the radial/diametric +/// branch in `std_assoc_refs`, which used to emit no reference for those kinds at all. +#[test] +fn angular_diameter_and_radius_dimensions_are_saved_with_dimassoc() { + let c = case(); + let text = write_dxf(&c.d); + + let dia = dimassoc(&text, c.hd).unwrap_or_else(|| panic!("diameter dimension {} has a DIMASSOC", c.hd.hex())); + let rad = dimassoc(&text, c.hr).unwrap_or_else(|| panic!("radius dimension {} has a DIMASSOC", c.hr.hex())); + let ang = dimassoc(&text, c.ha).unwrap_or_else(|| panic!("angular dimension {} has a DIMASSOC", c.ha.hex())); + assert!(dimassoc(&text, c.hl).is_some(), "the linear control still has one"); + + // Radial and diametric dimensions associate their circle once, by its centre, as AutoCAD + // writes them (90 = 2). + for (dm, what) in [(&dia, "diameter"), (&rad, "radius")] { + let refs = point_refs(dm); + assert_eq!(int(dm, 90), Some(2), "{what}: associativity flag"); + assert_eq!(refs.len(), 1, "{what}: one reference"); + assert_eq!(int(&refs[0], 72), Some(3), "{what}: a centre snap"); + assert!(string(&refs[0], 331).is_some_and(|h| h.eq_ignore_ascii_case(&c.circle.hex())), "{what}: to the circle"); + } + + // The angular dimension associates its two leg points and their intersection (90 = 7). + let refs = point_refs(&ang); + assert_eq!(int(&ang, 90), Some(7), "angular: three references"); + assert_eq!(refs.len(), 3, "angular: three references"); + for (i, (osnap, line)) in [(1, c.l1), (1, c.l2), (6, c.l1)].into_iter().enumerate() { + assert_eq!(int(&refs[i], 72), Some(osnap), "angular ref {i}"); + assert!(string(&refs[i], 331).is_some_and(|h| h.eq_ignore_ascii_case(&line.hex())), "angular ref {i}: to its line"); + } + assert!(string(&refs[2], 332).is_some_and(|h| h.eq_ignore_ascii_case(&c.l2.hex())), "angular: the intersection's other line"); +} + +/// The reader half of #1651, and the one that matters for interchange: with CADCraft's xdata +/// stripped, the standard DIMASSOC objects alone must rebuild the same associations, both from +/// DXF and through the DWG bridge. Guards the dimension-kind slot table and the radial centre +/// reference in `dxf_read::dimassoc`, plus the curve start/end osnap semantics in `infer_snap`. +#[test] +fn standard_dimassoc_associates_in_dxf_and_dwg() { + let c = case(); + let plain = without_cadcraft_xdata(&write_dxf(&c.d)); + + let back = read_dxf(plain.as_bytes()).unwrap(); + assert_standard_assoc(&c, &back, "DXF"); + + let dwg = cadcraft_dwg::dxf_to_dwg_as(plain.as_bytes(), Version::R2018.code()).unwrap(); + let back = read(&dwg, "case.dwg").unwrap(); + assert_standard_assoc(&c, &back, "DWG"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 240a97ef..6c852ba7 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-21 · **Change:** minor (proxy objects and entities keep their class through a save, #1812; previously minor: DWG read leaves out orphaned DGN line style components) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (DIMASSOC objects for angular, diametric and radial dimensions) · **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 @@ -76,6 +76,7 @@ on read with a note, so such files no longer exceed the DXF limit (#1482). **DWG | LINE, POINT, CIRCLE, ARC, ELLIPSE, LWPOLYLINE (widths, bulges, elevation), SPLINE, RAY, XLINE | yes | yes | round-trip tests | | POLYLINE 2D/3D | yes | yes (as LWPOLYLINE / 3D POLYLINE) | polyface and polygon meshes flattened: lossy | | TEXT, MTEXT, ATTDEF, INSERT + ATTRIB | yes; multiline attributes (embedded MTEXT) read and drawn | yes; multiline attributes written as embedded MTEXT (DXF 2007 and later; older versions: one line) | ATTDEF prompt kept (#137) | +| 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 for every associated kind — linear/aligned, angular, radial/diametric, ordinate and arc length — and read back without CADCraft's xdata (#1651) | | 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; angular block text honors DIMDSEP, including per-dimension overrides (#1646) | | DIMENSION (all 7 kinds) | yes | yes, with anonymous `*D` blocks; their text keeps the diameter sign as `%%c`, so SHX styles plot it (#1645) | a reopened dimension's `*D` block is regenerated under its name, unreferenced ones dropped (#495); DIMASSOC objects written | | LEADER | yes | yes | | @@ -139,6 +140,7 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | DXF/DWG save: angular, diametric and radial dimensions get standard DIMASSOC objects (one centre reference to the circle for radial/diametric, the definition points for angular), so AutoCAD follows their geometry; the standard objects of those kinds are read back too, without CADCraft's xdata (#1651) | | 2026-10-19 | minor | DXF/DWG save: angular dimension block text honors DIMDSEP, including per-dimension overrides (#1646) | | 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-20 | minor | DIMENSION: the `*D` block's MTEXT, and the MTEXT EXPLODE leaves, hold the diameter sign as `%%c` instead of the decoded U+2300, which no SHX font has, so other programs plotted diameter dimensions in an SHX style without the sign (#1645) |