mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 10:56:51 -06:00
* DXF/DWG: angular, diametric and radial dimensions get DIMASSOC (#1651) std_assoc_refs had slots only for linear/aligned and ordinate dimensions, so every other associative kind was saved without a DIMASSOC object: AutoCAD did not follow angular, diameter or radius dimensions, although CADCraft itself did through its xdata. Write the references AutoCAD writes for each kind: both lines of an angular dimension (4 point references; the 3-point kind its three definition points) and, for radial and diametric dimensions, the single centre reference to the circle or arc (associativity flag 2), which is how AutoCAD files store them. Read the same references back, with the circle's definition points reconstructed, so files from other writers associate those kinds too. Curve start references now use osnap 13 as AutoCAD writes them (1 is the end), which also lets a stored point of 0,0,0 resolve to the right end. Tests: the issue's commands through the engine, and a DXF/DWG round trip with CADCraft's xdata stripped so only the standard objects remain. * docs: document the functions touched by #1651 * DXF read: a line's osnap 1 with a stored point still picks the nearer end (other writers) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
9562d951df
commit
fef32761af
@@ -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::<i32>() 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<Vec<(i32, String)>> {
|
||||
let hex = dim.hex();
|
||||
let mut recs = Vec::new();
|
||||
let mut cur: Option<Vec<(i32, String)>> = 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<Vec<(i32, String)>> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<Vec<(i32, String)>> = 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<i64> {
|
||||
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<String> = 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");
|
||||
}
|
||||
@@ -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<DimAssoc> {
|
||||
let starts: Vec<usize> = 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::<Option<Vec<_>>>(),
|
||||
_ => 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<Vec2> {
|
||||
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<Handle>, at: Vec2, segment: Option<i64>) -> Option<AssocSnap> {
|
||||
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<Handle>, 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)?;
|
||||
|
||||
@@ -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<OsnapRef>) {
|
||||
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<OsnapRef>) {
|
||||
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<OsnapRef>) {
|
||||
(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<OsnapRef>) {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DimAssoc>) -> 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::<i32>() 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<Vec<(i32, String)>> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<Vec<(i32, String)>> = 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<Vec<(i32, String)>> {
|
||||
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<Vec<(i32, String)>> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur: Option<Vec<(i32, String)>> = 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<i64> {
|
||||
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<DimAssoc> {
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user