From 632c2d4f1f7a7d2ecae1bb06d55423b2093bf49a Mon Sep 17 00:00:00 2001 From: aldozar Date: Mon, 19 Oct 2026 00:28:52 -0600 Subject: [PATCH] DIMASSOC: reference polyline vertices by segment, as AutoCAD does (#1049) (#1064) * DIMASSOC: no standard reference to a polyline's vertex or segment (#1049) A dimension on a polyline vertex was written with an AcDbOsnapPointRef naming the polyline but no subentity (groups 73 and 91 both 0); AutoCAD then resolved it to the polyline's first segment when it regenerated the dimension, and the value changed without warning (210 became 5). Such points (LWPOLYLINE, 3D polyline, MLINE, also as the second object of an intersection) are now left out of the DIMASSOC object, as the report suggests: AutoCAD sees them as non-associative and the values stay right. CADCraft keeps every link through its own xdata, and references to single-curve objects (lines, arcs, circles, points) are written as before. Co-Authored-By: Claude Opus 5.5 * DIMASSOC: reference polyline vertices by segment, as AutoCAD does Following the black-box observation of AutoCAD 2026 in the PR review: a polyline vertex is referenced as an end of one of its segments, subentity type 73 = 2 (edge) and the 1-based segment in 91 (segment k runs from vertex k-1 to vertex k; a closed polyline's closing segment is the last), with osnap 1 / parameter 1.0 for a segment's end and 13 / 0.0 for its start. Vertex 0 is written as the start of segment 1, vertex k as the end of segment k, falling back to the other neighbour when that one is an arc; a vertex between two arcs (where AutoCAD writes an angle parameter, not yet tested) still gets no reference, nor do 3D polylines and multilines. Reading uses the segment and osnap for polylines: AutoCAD leaves the reference point at 0,0, so the nearest-vertex fallback attached AutoCAD's associative dimensions on polylines to the vertex nearest the origin. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/io/src/dxf_read.rs | 17 ++++-- crates/io/src/dxf_write.rs | 62 +++++++++++++++++++-- crates/io/src/tests.rs | 108 ++++++++++++++++++++++++++++++++++++- 3 files changed, 178 insertions(+), 9 deletions(-) diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 7615bc56..d5622686 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -2130,7 +2130,9 @@ fn dimassoc(d: &mut Drawing, tags: &[Tag]) { let Some(main) = r.s(331).and_then(|h| Handle::parse_hex(&h)) else { continue }; let other = r.s(332).and_then(|h| Handle::parse_hex(&h)); let at = r.p(10).xy(); - if let Some(snap) = infer_snap(d, r.i(72).unwrap_or(0), main, other, at) { + // A polyline's segment (subentity type 73 = 2, edge; 91 its 1-based index). + let segment = r.i(91).filter(|k| *k >= 1 && r.i(73) == Some(2)); + if let Some(snap) = infer_snap(d, r.i(72).unwrap_or(0), main, other, at, segment) { links.push(DimAssoc { point: (*point).to_string(), handle: main, snap }); } } @@ -2143,9 +2145,18 @@ fn dimassoc(d: &mut Drawing, tags: &[Tag]) { } } -/// A CADCraft snap from a DIMASSOC object snap type and point. -fn infer_snap(d: &Drawing, osnap: i64, main: Handle, other: Option, at: Vec2) -> Option { +/// 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. +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) { + let n = p.vertices.len(); + let segments = if p.closed { n } else { n.saturating_sub(1) }; + let k = usize::try_from(k).ok().filter(|k| *k <= segments)?; + let index = if osnap == 13 { k - 1 } else { k % n }; + return Some(AssocSnap::Vertex { index }); + } Some(match osnap { 1 | 13 => match &e.kind { EntityKind::Line(l) => { diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 3fd43215..d2f8941f 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -94,6 +94,9 @@ struct AssocObj { struct OsnapRef { osnap: i64, main: Handle, + /// The 1-based segment of a polyline the point is an end of (group 91, with subentity type + /// 73 = 2, edge); 0 for an object made of one curve. + segment: i64, param: f64, point: Vec3, other: Option, @@ -1506,9 +1509,43 @@ fn arrow_block_entities(kind: cadcraft_render::Arrowhead) -> Vec { out } +/// 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 +/// otherwise resolved them to the first segment and changed the dimension's value (#1049). +fn whole_object_ref(k: &EntityKind) -> bool { + !matches!(k, EntityKind::LwPolyline(_) | EntityKind::Polyline3d(_) | EntityKind::MLine(_)) +} + +/// A polyline vertex as AutoCAD references it (#1049): an end of one of its two segments, +/// which are numbered from 1 (segment k runs from vertex k−1 to vertex k; a closed polyline's +/// closing segment is the last). Returns (osnap, parameter, segment): the end of the incoming +/// segment (osnap 1, parameter 1.0), else the start of the outgoing one (osnap 13, 0.0). Only +/// straight segments are used, whose parameters are 0 and 1; a vertex between two arcs gets no +/// reference. +pub(crate) fn polyline_vertex_ref(p: &LwPolyline, index: usize) -> Option<(i64, f64, i64)> { + let n = p.vertices.len(); + if index >= n || n < 2 { + return None; + } + let segments = if p.closed { n } else { n - 1 }; + // Segment k (1-based) starts at vertex k−1; its bulge is that vertex's. + let straight = |k: usize| k >= 1 && k <= segments && p.vertices.get(k - 1).is_some_and(|v| v.bulge == 0.0); + let incoming = if index == 0 { if p.closed { Some(n) } else { None } } else { Some(index) }; + let outgoing = (index < segments).then_some(index + 1); + // Vertex 0 as the start of segment 1, the others as the end of the segment before, as + // AutoCAD writes them when it can. + let preferred = if index == 0 { + [outgoing.map(|k| (13, 0.0, k)), incoming.map(|k| (1, 1.0, k))] + } else { + [incoming.map(|k| (1, 1.0, k)), outgoing.map(|k| (13, 0.0, k))] + }; + preferred.into_iter().flatten().find(|(_, _, k)| straight(*k)).map(|(o, t, k)| (o, t, i64::try_from(k).unwrap_or(0))) +} + /// Standard DIMASSOC point references of an associative dimension: the extension-line /// origins of linear/aligned dimensions and the feature point of ordinate dimensions, linked -/// to objects that exist. CADCraft's exact links travel in xdata as well. +/// 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) { let slots: &[&str] = match dm.kind { DimKind::Linear { .. } | DimKind::Aligned => &["p13", "p14"], @@ -1521,20 +1558,35 @@ fn std_assoc_refs(d: &Drawing, dm: &Dimension) -> (i64, Vec) { 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 }; + if let EntityKind::LwPolyline(p) = &ent.kind { + let index = match &a.snap { + AssocSnap::Vertex { index } => Some(*index), + AssocSnap::Start => Some(0), + AssocSnap::End => p.vertices.len().checked_sub(1), + _ => None, + }; + let Some((osnap, param, segment)) = index.and_then(|i| polyline_vertex_ref(p, i)) else { continue }; + flags |= 1 << slot; + refs.push(OsnapRef { osnap, main: a.handle, segment, param, point, other: None }); + continue; + } + if !whole_object_ref(&ent.kind) { + continue; + } let (osnap, param, other) = match &a.snap { AssocSnap::Start | AssocSnap::End | 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), AssocSnap::Intersection { other } => { - if d.entity(*other).is_none() { + if !d.entity(*other).is_some_and(|o| whole_object_ref(&o.kind)) { continue; } (6, 0.0, Some(*other)) } }; flags |= 1 << slot; - refs.push(OsnapRef { osnap, main: a.handle, param, point, other }); + refs.push(OsnapRef { osnap, main: a.handle, segment: 0, param, point, other }); } (flags, refs) } @@ -2450,8 +2502,8 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { w.s(1, "AcDbOsnapPointRef"); w.i(72, r.osnap); w.s(331, r.main.hex()); - w.i(73, 0); - w.i(91, 0); + w.i(73, if r.segment > 0 { 2 } else { 0 }); + w.i(91, r.segment); w.s(301, ""); w.f(40, r.param); w.p(10, r.point); diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 1fa53c6e..20a18fe5 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -1086,11 +1086,117 @@ fn dimension_associativity_roundtrips() { assert_eq!(assoc_of(&foreign, hl), lin.assoc); let fa = assoc_of(&foreign, ha); assert_eq!(fa.len(), 2); - assert_eq!(fa[1], ali.assoc[1]); + 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"); } +/// #1049: a polyline vertex is referenced as AutoCAD does: an end of one of its segments, +/// numbered from 1 (subentity type 73 = 2, segment in 91, osnap 1 end / 13 start, parameter 1 / +/// 0), preferring a straight segment. The cases are the ones observed in AutoCAD 2026 (black box) +/// in the PR review. +#[test] +fn polyline_vertex_references_name_the_segment() { + let poly = |pts: &[(f64, f64)], closed: bool, bulges: &[(usize, f64)]| LwPolyline { + vertices: pts + .iter() + .enumerate() + .map(|(i, (x, y))| { + let b = bulges.iter().find(|(j, _)| *j == i).map_or(0.0, |(_, b)| *b); + PolyVertex::with_bulge(Vec2::new(*x, *y), b) + }) + .collect(), + closed, + const_width: 0.0, + elevation: 0.0, + plinegen: false, + }; + let refs = |p: &LwPolyline| (0..p.vertices.len()).map(|i| crate::dxf_write::polyline_vertex_ref(p, i)).collect::>(); + // Open: vertex 0 is the start of segment 1, vertex k the end of segment k. + let open = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 60.0), (40.0, 60.0)], false, &[]); + assert_eq!(refs(&open), [Some((13, 0.0, 1)), Some((1, 1.0, 1)), Some((1, 1.0, 2)), Some((1, 1.0, 3))]); + // Closed (clockwise): the same; the closing segment is the 4th. + let closed = poly(&[(0.0, 0.0), (0.0, 60.0), (100.0, 60.0), (100.0, 0.0)], true, &[]); + assert_eq!(refs(&closed), [Some((13, 0.0, 1)), Some((1, 1.0, 1)), Some((1, 1.0, 2)), Some((1, 1.0, 3))]); + // An arc as segment 3: its end, vertex 3, is the start of the straight segment 4 instead. + let arc = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 60.0), (40.0, 60.0), (0.0, 30.0)], true, &[(2, 0.5)]); + assert_eq!(refs(&arc)[3], Some((13, 0.0, 4))); + assert_eq!(refs(&arc)[2], Some((1, 1.0, 2))); + // Between two arcs there is no straight segment: no reference. + let arcs = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 60.0)], false, &[(0, 0.5), (1, 0.5)]); + assert_eq!(refs(&arcs)[1], None); +} + +/// #1049: dimensions on the vertices of a polyline are written with segment references, and a +/// file with only those (another writer's, whose reference points AutoCAD leaves at 0,0) is read +/// back to the same vertices. +#[test] +fn polyline_vertex_dimensions_round_trip_by_segment() { + let mut d = Drawing::new_metric(); + let c = Common::default; + let pts = [(0.0, 0.0), (160.0, 0.0), (160.0, 210.0), (0.0, 210.0)]; + let pl = d + .add( + &Space::Model, + c(), + EntityKind::LwPolyline(LwPolyline { + vertices: pts.iter().map(|(x, y)| PolyVertex::new(Vec2::new(*x, *y))).collect(), + closed: true, + const_width: 0.0, + elevation: 0.0, + plinegen: false, + }), + ) + .unwrap(); + let mut on_pl = dim(DimKind::Linear { rotation: std::f64::consts::FRAC_PI_2 }, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 210.0, 0.0)); + on_pl.assoc = vec![ + DimAssoc { point: "p13".into(), handle: pl, snap: AssocSnap::Vertex { index: 0 } }, + DimAssoc { point: "p14".into(), handle: pl, snap: AssocSnap::Vertex { index: 3 } }, + ]; + let hp = d.add(&Space::Model, c(), EntityKind::Dimension(on_pl.clone())).unwrap(); + let text = write_dxf(&d); + let tags = cadcraft_dxf::parse(text.as_bytes()).unwrap(); + // Each reference to the polyline: (72, 73, 91). + let refs: Vec<(i64, i64, i64)> = tags + .iter() + .enumerate() + .filter(|(_, t)| t.code == 331 && t.str() == pl.hex()) + .map(|(i, _)| { + let back = |code: i32| tags[..i].iter().rev().find(|t| t.code == code).and_then(|t| t.str().parse().ok()).unwrap_or(-1); + let fwd = |code: i32| tags[i..].iter().find(|t| t.code == code).and_then(|t| t.str().parse().ok()).unwrap_or(-1); + (back(72), fwd(73), fwd(91)) + }) + .collect(); + assert_eq!(refs, [(13, 2, 1), (1, 2, 3)]); + + // Without CADCraft's xdata and with the reference points at 0,0 (as AutoCAD writes them), + // the segments still give the vertices. + let mut stripped = Vec::new(); + let mut skipping = false; + let mut in_ref = false; + for mut t in tags { + if t.code == 1001 { + skipping = t.str() == "CADCRAFT"; + } else if t.code < 1000 { + skipping = false; + } + if t.code == 1 { + in_ref = t.str() == "AcDbOsnapPointRef"; + } + if in_ref && matches!(t.code, 10 | 20) { + t = cadcraft_dxf::Tag::new(t.code, cadcraft_dxf::Value::Real(0.0)); + } + if !skipping { + stripped.push(t); + } + } + let foreign = read_dxf(cadcraft_dxf::write_ascii(&stripped).as_bytes()).unwrap(); + match &foreign.entity(hp).unwrap().kind { + EntityKind::Dimension(x) => assert_eq!(x.assoc, on_pl.assoc), + _ => panic!(), + } +} + fn parametric_sample(d: &mut Drawing) -> (Vec, Parametric) { let l1 = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(4.0, 0.0, 0.0) })).unwrap(); let l2 = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(4.0, 0.0, 0.0), b: Vec3::new(4.0, 3.0, 0.0) })).unwrap();